From f16c267bad1e738a6fd036d5a3dd9a7c573f8560 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sat, 30 Aug 2025 15:49:54 +0100 Subject: [PATCH 01/34] fix(featuredetection): add feature detection for advanced issue search Signed-off-by: Babak K. Shandiz --- .../featuredetection/feature_detection.go | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index c61f47aeb..ad1a0c092 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -16,6 +16,7 @@ type Detector interface { PullRequestFeatures() (PullRequestFeatures, error) RepositoryFeatures() (RepositoryFeatures, error) ProjectsV1() gh.ProjectsV1Support + SearchFeatures() (SearchFeatures, error) } type IssueFeatures struct { @@ -55,6 +56,48 @@ var allRepositoryFeatures = RepositoryFeatures{ AutoMerge: true, } +type SearchFeatures struct { + // AdvancedIssueSearch indicates whether the host supports advanced issue + // search via API calls. + AdvancedIssueSearchAPI bool + // AdvancedIssueSearchOptIn indicates whether the host supports advanced + // issue search as an opt-in feature, which has to be explicitly enabled in + // API calls. + AdvancedIssueSearchAPIOptIn bool + // AdvancedIssueSearchWebInIssuesTab indicates whether the host supports + // advanced issue search syntax in the Issues tab of repositories. + AdvancedIssueSearchWebInIssuesTab bool + + // TODO(babakks): when advanced issue search is supported in Pull Requests + // tab, or in global search we can introduce more fields to reflect the + // support status +} + +// advancedIssueSearchNotSupported mimics GHE <3.18 where advanced issue search +// is either not supported or is not meant to be used due to not being stable +// enough (i.e. in preview). +var advancedIssueSearchNotSupported = SearchFeatures{ + AdvancedIssueSearchAPI: false, +} + +// advancedIssueSearchSupportedAsOptIn mimics github.com and GHE >=3.18 before +// the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is still +// present on the schema). +var advancedIssueSearchSupportedAsOptIn = SearchFeatures{ + AdvancedIssueSearchAPI: true, + AdvancedIssueSearchAPIOptIn: true, + AdvancedIssueSearchWebInIssuesTab: true, +} + +// advancedIssueSearchSupportedAsOnlyBackend mimics github.com and GHE >=3.18 +// after the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is +// removed from the schema). +var advancedIssueSearchSupportedAsOnlyBackend = SearchFeatures{ + AdvancedIssueSearchAPI: true, + AdvancedIssueSearchAPIOptIn: false, + AdvancedIssueSearchWebInIssuesTab: true, +} + type detector struct { host string httpClient *http.Client @@ -225,6 +268,92 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support { return gh.ProjectsV1Unsupported } +const ( + enterpriseAdvancedIssueSearchSupport = "3.18.0" +) + +func (d *detector) SearchFeatures() (SearchFeatures, error) { + // Regarding the release of advanced issue search (AIS, for short), there + // are three time spans/periods: + // + // 1. Pre-deprecation (< 4 Sep): where both legacy search and AIS are available + // - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave differently + // - REST: `advance_search=true` query parameter can be used to switch to AIS + // 2. Deprecation (>= 4 Sep): only AIS available + // - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave the same (AIS) + // - REST: `advance_search` query parameter has no effect (AIS) + // 3. Cleanup (>= TBD): only AIS available + // - GraphQL: `ISSUE` search type in GraphQL is the only available option (AIS) + // - REST: `advance_search` query parameter has no effect (AIS) + // + // Since there's no schema-wise difference between pre-deprecation and + // deprecation periods (i.e. `ISSUE_ADVANCED` is available during both), + // we cannot figure out the exact time period. The consensus is to to use + // the advanced search syntax during both periods. + + var feature SearchFeatures + + if ghauth.IsEnterprise(d.host) { + enterpriseAISSupportVersion, err := version.NewVersion(enterpriseAdvancedIssueSearchSupport) + if err != nil { + return SearchFeatures{}, err + } + + hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + if err != nil { + return SearchFeatures{}, err + } + + if hostVersion.GreaterThanOrEqual(enterpriseAISSupportVersion) { + // As of August 2025, advanced issue search is going to be available + // on GHES 3.18+, including Issues tabs in repositories. + feature.AdvancedIssueSearchAPI = true + feature.AdvancedIssueSearchWebInIssuesTab = true + + // TODO(babakks): when the advanced search syntax is supported in + // global search or Pull Requests tabs (in repositories), we can + // enable the corresponding fields. + } + } else { + // As of August 2025, advanced issue search is available on github.com, + // including Issues tabs in repositories. + feature.AdvancedIssueSearchAPI = true + feature.AdvancedIssueSearchWebInIssuesTab = true + + // TODO(babakks): when the advanced search syntax is supported in global + // search or Pull Requests tabs (in repositories), we can enable the + // corresponding fields. + } + + if !feature.AdvancedIssueSearchAPI { + return feature, nil + } + + var searchTypeFeatureDetection struct { + SearchType struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"SearchType: __type(name: \"SearchType\")"` + } + + gql := api.NewClientFromHTTP(d.httpClient) + if err := gql.Query(d.host, "SearchType_enumValues", &searchTypeFeatureDetection, nil); err != nil { + return SearchFeatures{}, err + } + + for _, enumValue := range searchTypeFeatureDetection.SearchType.EnumValues { + if enumValue.Name == "ISSUE_ADVANCED" { + // As long as ISSUE_ADVANCED is present on the schema, we should + // explicitly opt-in when making API calls. + feature.AdvancedIssueSearchAPIOptIn = true + break + } + } + + return feature, nil +} + func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { var metaResponse struct { InstalledVersion string `json:"installed_version"` From e9b3ac364a2d8f3c41b477d1e764d3989fd928b6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sat, 30 Aug 2025 15:51:57 +0100 Subject: [PATCH 02/34] test(featuredetection): add tests for advanced issue search detection Signed-off-by: Babak K. Shandiz --- internal/featuredetection/detector_mock.go | 35 +++++ .../feature_detection_test.go | 146 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 6f760f209..717e3d6a9 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support { return gh.ProjectsV1Unsupported } +func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) { + return advancedIssueSearchNotSupported, nil +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -37,3 +41,34 @@ func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support { return gh.ProjectsV1Supported } + +func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) { + return advancedIssueSearchNotSupported, nil +} + +type AdvancedIssueSearchDetectorMock struct { + EnabledDetectorMock + searchFeatures SearchFeatures +} + +func (md *AdvancedIssueSearchDetectorMock) SearchFeatures() (SearchFeatures, error) { + return md.searchFeatures, nil +} + +func AdvancedIssueSearchUnsupported() *AdvancedIssueSearchDetectorMock { + return &AdvancedIssueSearchDetectorMock{ + searchFeatures: advancedIssueSearchNotSupported, + } +} + +func AdvancedSearchSupportedAsOptIn() *AdvancedIssueSearchDetectorMock { + return &AdvancedIssueSearchDetectorMock{ + searchFeatures: advancedIssueSearchSupportedAsOptIn, + } +} + +func AdvancedSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMock { + return &AdvancedIssueSearchDetectorMock{ + searchFeatures: advancedIssueSearchSupportedAsOnlyBackend, + } +} diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index e850546a7..18a846268 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -439,3 +439,149 @@ func TestProjectV1Support(t *testing.T) { }) } } + +func TestAdvancedIssueSearchSupport(t *testing.T) { + withIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"ISSUE_ADVANCED"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}` + withoutIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}` + + tests := []struct { + name string + hostname string + httpStubs func(*httpmock.Registry) + wantFeatures SearchFeatures + }{ + { + name: "github.com, before ISSUE_ADVANCED cleanup", + hostname: "github.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOptIn, + }, + { + name: "github.com, after ISSUE_ADVANCED cleanup", + hostname: "github.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withoutIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOnlyBackend, + }, + { + name: "ghec data residency (ghe.com), before ISSUE_ADVANCED cleanup", + hostname: "stampname.ghe.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOptIn, + }, + { + name: "ghec data residency (ghe.com), after ISSUE_ADVANCED cleanup", + hostname: "stampname.ghe.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withoutIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOnlyBackend, + }, + { + name: "GHE 3.18, before ISSUE_ADVANCED cleanup", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.18.0"}`), + ) + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOptIn, + }, + { + name: "GHE 3.18, after ISSUE_ADVANCED cleanup", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.18.0"}`), + ) + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withoutIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOnlyBackend, + }, + { + name: "GHE >3.18, before ISSUE_ADVANCED cleanup", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.18.1"}`), + ) + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOptIn, + }, + { + name: "GHE >3.18, after ISSUE_ADVANCED cleanup", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.18.1"}`), + ) + reg.Register( + httpmock.GraphQL(`query SearchType_enumValues\b`), + httpmock.StringResponse(withoutIssueAdvanced), + ) + }, + wantFeatures: advancedIssueSearchSupportedAsOnlyBackend, + }, + { + name: "GHE <3.18 (no advanced issue search support)", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.17.999"}`), + ) + }, + wantFeatures: advancedIssueSearchNotSupported, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + + detector := NewDetector(httpClient, tt.hostname) + + features, err := detector.SearchFeatures() + require.NoError(t, err) + require.Equal(t, tt.wantFeatures, features) + }) + } +} From fd38b14898ae0951064749fb400987152ccf1dc3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 01:47:42 +0100 Subject: [PATCH 03/34] fix(search): add `AdvancedIssueSearchString` method Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 92 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index c517f90d8..9f874de18 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -3,6 +3,7 @@ package search import ( "fmt" "reflect" + "slices" "sort" "strings" "unicode" @@ -86,13 +87,88 @@ type Qualifiers struct { User []string } +// String returns the string representation of the query which can be used with +// search API (except for advanced issue search), global search GUI (i.e. +// github.com/search), or Pull Requests tab (in repositories). func (q Query) String() string { - qualifiers := formatQualifiers(q.Qualifiers) + qualifiers := formatQualifiers(q.Qualifiers, nil) keywords := formatKeywords(q.Keywords) all := append(keywords, qualifiers...) return strings.TrimSpace(strings.Join(all, " ")) } +// AdvancedIssueSearchString returns the string representation of the query +// compatible with the advanced issue search syntax. The query can be used in +// Issues tab (of repositories) and the Issues dashboard (i.e. +// github.com/issues). +func (q Query) AdvancedIssueSearchString() string { + qualifiers := formatQualifiers(q.Qualifiers, formatAdvancedIssueSearch) + keywords := formatKeywords(q.Keywords) + all := append(keywords, qualifiers...) + return strings.TrimSpace(strings.Join(all, " ")) +} + +func formatAdvancedIssueSearch(qualifier string, vs []string) (s string, applicable bool) { + switch qualifier { + case "in": + return formatSpecialQualifiers("in", vs, []string{"title", "body", "comments"}), true + case "is": + return formatSpecialQualifiers("is", vs, []string{"private", "public"}), true + case "user", "repo": + return groupWithOR(qualifier, vs), true + } + // Let the default formatting take over + return "", false +} + +func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) string { + specials := make([]string, 0, len(vs)) + rest := make([]string, 0, len(vs)) + for _, v := range vs { + if slices.Contains(valuesToOR, v) { + specials = append(specials, v) + } else { + rest = append(rest, v) + } + } + + all := make([]string, 0, 2) + if len(specials) > 0 { + all = append(all, groupWithOR(qualifier, specials)) + } + if len(rest) > 0 { + all = append(all, concat(qualifier, rest)) + } + return strings.Join(all, " ") +} + +func groupWithOR(qualifier string, vs []string) string { + if len(vs) == 0 { + return "" + } + + all := make([]string, 0, len(vs)) + for _, v := range vs { + all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v))) + } + + if len(all) == 1 { + return all[0] + } + return fmt.Sprintf("(%s)", strings.Join(all, " OR ")) +} + +func concat(qualifier string, vs []string) string { + if len(vs) == 0 { + return "" + } + all := make([]string, 0, len(vs)) + for _, v := range vs { + all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v))) + } + return strings.Join(all, " ") +} + func (q Qualifiers) Map() map[string][]string { m := map[string][]string{} v := reflect.ValueOf(q) @@ -138,9 +214,21 @@ func quote(s string) string { return s } -func formatQualifiers(qs Qualifiers) []string { +// formatQualifiers renders qualifiers into a plain query. +// +// The formatter is a custom formatting function that can be used to modify the +// output of each qualifier. If the formatter returns ("", false) the default +// formatting will be applied. +func formatQualifiers(qs Qualifiers, formatter func(qualifier string, vs []string) (s string, applicable bool)) []string { var all []string for k, vs := range qs.Map() { + if formatter != nil { + if s, applicable := formatter(k, vs); applicable { + all = append(all, s) + continue + } + } + for _, v := range vs { all = append(all, fmt.Sprintf("%s:%s", k, quote(v))) } From 392c286db37eec28d1effcabacc6cfc450b82c66 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 01:49:00 +0100 Subject: [PATCH 04/34] test(search): add tests for `AdvancedIssueSearchString` method Signed-off-by: Babak K. Shandiz --- pkg/search/query_test.go | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index ddec211cb..064a11f28 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -78,6 +78,69 @@ func TestQueryString(t *testing.T) { } } +func TestAdvancedIssueSearchString(t *testing.T) { + tests := []struct { + name string + query Query + out string + }{ + { + name: "quotes keywords", + query: Query{ + Keywords: []string{"quote keywords"}, + }, + out: `"quote keywords"`, + }, + { + name: "quotes keywords that are qualifiers", + query: Query{ + Keywords: []string{"quote:keywords", "quote:multiword keywords"}, + }, + out: `quote:keywords quote:"multiword keywords"`, + }, + { + name: "quotes qualifiers", + query: Query{ + Qualifiers: Qualifiers{ + Label: []string{"quote qualifier"}, + }, + }, + out: `label:"quote qualifier"`, + }, + { + name: "special qualifiers when used once", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + Repo: []string{"foo/bar"}, + Is: []string{"private"}, + User: []string{"johndoe"}, + In: []string{"title"}, + }, + }, + out: `keyword in:title is:private repo:foo/bar user:johndoe`, + }, + { + name: "special qualifiers are OR-ed when used multiple times", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + Repo: []string{"foo/bar", "foo/baz"}, + Is: []string{"private", "public", "foo"}, // "foo" is to ensure only "public" and "private" are grouped + User: []string{"johndoe", "janedoe"}, + In: []string{"title", "body", "comments", "foo"}, // "foo" is to ensure only "title", "body", and "comments" are grouped + }, + }, + out: `keyword (in:title OR in:body OR in:comments) in:foo (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:johndoe OR user:janedoe)`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, tt.query.AdvancedIssueSearchString()) + }) + } +} + func TestQualifiersMap(t *testing.T) { tests := []struct { name string From bf242ae2f4b81c127e07e5b3f49cef6f09a5167c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 02:00:27 +0100 Subject: [PATCH 05/34] fix(search): sort qualifiers in advacned issue search syntax Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 19 +++++++------------ pkg/search/query_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index 9f874de18..48630fca1 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -137,8 +137,12 @@ func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) all = append(all, groupWithOR(qualifier, specials)) } if len(rest) > 0 { - all = append(all, concat(qualifier, rest)) + for _, v := range rest { + all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v))) + } } + + slices.Sort(all) return strings.Join(all, " ") } @@ -155,18 +159,9 @@ func groupWithOR(qualifier string, vs []string) string { if len(all) == 1 { return all[0] } - return fmt.Sprintf("(%s)", strings.Join(all, " OR ")) -} -func concat(qualifier string, vs []string) string { - if len(vs) == 0 { - return "" - } - all := make([]string, 0, len(vs)) - for _, v := range vs { - all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v))) - } - return strings.Join(all, " ") + slices.Sort(all) + return fmt.Sprintf("(%s)", strings.Join(all, " OR ")) } func (q Qualifiers) Map() map[string][]string { diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 064a11f28..81b53d4cf 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -131,7 +131,33 @@ func TestAdvancedIssueSearchString(t *testing.T) { In: []string{"title", "body", "comments", "foo"}, // "foo" is to ensure only "title", "body", and "comments" are grouped }, }, - out: `keyword (in:title OR in:body OR in:comments) in:foo (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:johndoe OR user:janedoe)`, + out: `keyword (in:body OR in:comments OR in:title) in:foo (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)`, + }, + { + name: "special qualifiers without special values", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + Is: []string{"foo", "bar"}, + In: []string{"foo", "bar"}, + }, + }, + out: `keyword in:bar in:foo is:bar is:foo`, + }, + { + name: "non-special qualifiers used multiple times", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + In: []string{"foo", "bar"}, + Is: []string{"foo", "bar"}, + Label: []string{"foo", "bar"}, + License: []string{"foo", "bar"}, + No: []string{"foo", "bar"}, + Topic: []string{"foo", "bar"}, + }, + }, + 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`, }, } for _, tt := range tests { From cb249e6cbbe238429da6902e3b87d33e3ba52f19 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 02:02:25 +0100 Subject: [PATCH 06/34] test(search): explain why `is:` and `in:` qualifiers used in test case Signed-off-by: Babak K. Shandiz --- pkg/search/query_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 81b53d4cf..f99f73178 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -149,8 +149,8 @@ func TestAdvancedIssueSearchString(t *testing.T) { query: Query{ Keywords: []string{"keyword"}, Qualifiers: Qualifiers{ - In: []string{"foo", "bar"}, - Is: []string{"foo", "bar"}, + In: []string{"foo", "bar"}, // "in:" is a special qualifier but its values here are not special + Is: []string{"foo", "bar"}, // "is:" is a special qualifier but its values here are not special Label: []string{"foo", "bar"}, License: []string{"foo", "bar"}, No: []string{"foo", "bar"}, From 257f143711a307cc359384734e0bff777c5983df Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 12:24:05 +0100 Subject: [PATCH 07/34] fix(search): add feature detection dependency Signed-off-by: Babak K. Shandiz --- pkg/cmd/extension/command.go | 7 +++++-- pkg/cmd/search/shared/shared.go | 7 ++++++- pkg/search/searcher.go | 13 ++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 354a91cc3..057f91140 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -12,6 +12,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" @@ -164,7 +165,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { query.Qualifiers = qualifiers host, _ := cfg.Authentication().DefaultHost() - searcher := search.NewSearcher(client, host) + detector := featuredetection.NewDetector(client, host) + searcher := search.NewSearcher(client, host, detector) if webMode { url := searcher.URL(query) @@ -507,7 +509,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return err } - searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host) + detector := featuredetection.NewDetector(client, host) + searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host, detector) gc.Stderr = gio.Discard diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index 3282599cf..1989bb9d3 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -42,12 +43,16 @@ func Searcher(f *cmdutil.Factory) (search.Searcher, error) { if err != nil { return nil, err } + host, _ := cfg.Authentication().DefaultHost() client, err := f.HttpClient() if err != nil { return nil, err } - return search.NewSearcher(client, host), nil + + detector := fd.NewDetector(client, host) + + return search.NewSearcher(client, host, detector), nil } func SearchIssues(opts *IssuesOptions) error { diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 7cbd35562..9070f7a11 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" ) @@ -34,8 +35,9 @@ type Searcher interface { } type searcher struct { - client *http.Client - host string + client *http.Client + detector fd.Detector + host string } type httpError struct { @@ -52,10 +54,11 @@ type httpErrorItem struct { Resource string } -func NewSearcher(client *http.Client, host string) Searcher { +func NewSearcher(client *http.Client, host string, detector fd.Detector) Searcher { return &searcher{ - client: client, - host: host, + client: client, + host: host, + detector: detector, } } From 188098d592bd0e17755ef2f234d82a3539fcbb82 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 12:25:36 +0100 Subject: [PATCH 08/34] test(search): provide feature detection dependency Signed-off-by: Babak K. Shandiz --- pkg/cmd/extension/browse/browse_test.go | 3 ++- pkg/cmd/search/code/code_test.go | 7 ++++--- pkg/search/searcher_test.go | 11 ++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/extension/browse/browse_test.go b/pkg/cmd/extension/browse/browse_test.go index 956ea0fc4..8120da4d3 100644 --- a/pkg/cmd/extension/browse/browse_test.go +++ b/pkg/cmd/extension/browse/browse_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/repo/view" @@ -125,7 +126,7 @@ func Test_getExtensionRepos(t *testing.T) { }), ) - searcher := search.NewSearcher(client, "github.com") + searcher := search.NewSearcher(client, "github.com", &fd.DisabledDetectorMock{}) emMock := &extensions.ExtensionManagerMock{} emMock.ListFunc = func() []extensions.Extension { return []extensions.Extension{ diff --git a/pkg/cmd/search/code/code_test.go b/pkg/cmd/search/code/code_test.go index efb5f4b57..471a9d0cb 100644 --- a/pkg/cmd/search/code/code_test.go +++ b/pkg/cmd/search/code/code_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" @@ -336,7 +337,7 @@ func TestCodeRun(t *testing.T) { Extension: "go", }, }, - Searcher: search.NewSearcher(nil, "github.com"), + Searcher: search.NewSearcher(nil, "github.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://github.com/search?q=map+path%3Atesting.go&type=code", @@ -354,7 +355,7 @@ func TestCodeRun(t *testing.T) { Extension: ".cpp", }, }, - Searcher: search.NewSearcher(nil, "github.com"), + Searcher: search.NewSearcher(nil, "github.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://github.com/search?q=map+path%3Atesting.cpp&type=code", @@ -381,7 +382,7 @@ func TestCodeRun(t *testing.T) { Extension: "go", }, }, - Searcher: search.NewSearcher(nil, "example.com"), + Searcher: search.NewSearcher(nil, "example.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://example.com/search?q=map+extension%3Ago+filename%3Atesting&type=code", diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index fb7bb616a..0af192720 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" ) @@ -265,7 +266,7 @@ func TestSearcherCode(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host) + searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Code(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -551,7 +552,7 @@ func TestSearcherCommits(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host) + searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Commits(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -837,7 +838,7 @@ func TestSearcherRepositories(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host) + searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Repositories(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -1123,7 +1124,7 @@ func TestSearcherIssues(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host) + searcher := NewSearcher(client, tt.host, fd.AdvancedIssueSearchUnsupported()) result, err := searcher.Issues(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -1179,7 +1180,7 @@ func TestSearcherURL(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(nil, tt.host) + searcher := NewSearcher(nil, tt.host, nil) assert.Equal(t, tt.url, searcher.URL(tt.query)) }) } From 3086b6fc8a72d55aab591fa312b278b1773f5a98 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 13:54:52 +0100 Subject: [PATCH 09/34] refactor(search): sort qualifiers in query Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 49 +++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index 48630fca1..fbe708938 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -4,7 +4,6 @@ import ( "fmt" "reflect" "slices" - "sort" "strings" "unicode" ) @@ -108,20 +107,20 @@ func (q Query) AdvancedIssueSearchString() string { return strings.TrimSpace(strings.Join(all, " ")) } -func formatAdvancedIssueSearch(qualifier string, vs []string) (s string, applicable bool) { +func formatAdvancedIssueSearch(qualifier string, vs []string) (s []string, applicable bool) { switch qualifier { case "in": return formatSpecialQualifiers("in", vs, []string{"title", "body", "comments"}), true case "is": return formatSpecialQualifiers("is", vs, []string{"private", "public"}), true case "user", "repo": - return groupWithOR(qualifier, vs), true + return []string{groupWithOR(qualifier, vs)}, true } // Let the default formatting take over - return "", false + return nil, false } -func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) string { +func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) []string { specials := make([]string, 0, len(vs)) rest := make([]string, 0, len(vs)) for _, v := range vs { @@ -143,7 +142,7 @@ func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) } slices.Sort(all) - return strings.Join(all, " ") + return all } func groupWithOR(qualifier string, vs []string) string { @@ -212,24 +211,48 @@ func quote(s string) string { // formatQualifiers renders qualifiers into a plain query. // // The formatter is a custom formatting function that can be used to modify the -// output of each qualifier. If the formatter returns ("", false) the default +// output of each qualifier. If the formatter returns (nil, false) the default // formatting will be applied. -func formatQualifiers(qs Qualifiers, formatter func(qualifier string, vs []string) (s string, applicable bool)) []string { - var all []string +func formatQualifiers(qs Qualifiers, formatter func(qualifier string, vs []string) (s []string, applicable bool)) []string { + type entry struct { + key string + values []string + } + + var all []entry for k, vs := range qs.Map() { + if len(vs) == 0 { + continue + } + + e := entry{key: k} + if formatter != nil { if s, applicable := formatter(k, vs); applicable { - all = append(all, s) + e.values = s + all = append(all, e) continue } } for _, v := range vs { - all = append(all, fmt.Sprintf("%s:%s", k, quote(v))) + e.values = append(e.values, fmt.Sprintf("%s:%s", k, quote(v))) } + if len(e.values) > 1 { + slices.Sort(e.values) + } + all = append(all, e) } - sort.Strings(all) - return all + + slices.SortFunc(all, func(a, b entry) int { + return strings.Compare(a.key, b.key) + }) + + result := make([]string, 0, len(all)) + for _, e := range all { + result = append(result, e.values...) + } + return result } func formatKeywords(ks []string) []string { From 0104d8c0db59f1636622c532c580e2aea0d75be6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 13:57:47 +0100 Subject: [PATCH 10/34] refactor: improve mock feature detector names Signed-off-by: Babak K. Shandiz --- internal/featuredetection/detector_mock.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 717e3d6a9..2d41c707f 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -61,13 +61,13 @@ func AdvancedIssueSearchUnsupported() *AdvancedIssueSearchDetectorMock { } } -func AdvancedSearchSupportedAsOptIn() *AdvancedIssueSearchDetectorMock { +func AdvancedIssueSearchSupportedAsOptIn() *AdvancedIssueSearchDetectorMock { return &AdvancedIssueSearchDetectorMock{ searchFeatures: advancedIssueSearchSupportedAsOptIn, } } -func AdvancedSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMock { +func AdvancedIssueSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMock { return &AdvancedIssueSearchDetectorMock{ searchFeatures: advancedIssueSearchSupportedAsOnlyBackend, } From 1b2e2a24b6a941b1aba9ea8a026a4c9f165a9581 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 13:58:59 +0100 Subject: [PATCH 11/34] fix(search): use advanced issue search when available Signed-off-by: Babak K. Shandiz --- pkg/search/searcher.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 9070f7a11..6d0cccfc4 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -208,7 +208,27 @@ func (s searcher) search(query Query, result interface{}) (*http.Response, error qs := url.Values{} qs.Set("page", strconv.Itoa(query.Page)) qs.Set("per_page", strconv.Itoa(query.Limit)) - qs.Set("q", query.String()) + + if query.Kind == KindIssues { + features, err := s.detector.SearchFeatures() + if err != nil { + return nil, err + } + + if !features.AdvancedIssueSearchAPI { + qs.Set("q", query.String()) + } else { + qs.Set("q", query.AdvancedIssueSearchString()) + + if features.AdvancedIssueSearchAPIOptIn { + // Advanced syntax should be explicitly enabled + qs.Set("advanced_search", "true") + } + } + } else { + qs.Set("q", query.String()) + } + if query.Order != "" { qs.Set(orderKey, query.Order) } From 89b39e2df0668c5c00833bc454d397c17373633e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 13:59:45 +0100 Subject: [PATCH 12/34] test(search): test advanced search support Signed-off-by: Babak K. Shandiz --- pkg/search/searcher_test.go | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 0af192720..df30ae6ac 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1136,6 +1136,82 @@ func TestSearcherIssues(t *testing.T) { } } +func TestSearcherIssuesAdvancedSyntax(t *testing.T) { + query := Query{ + Kind: KindIssues, + Limit: 1, + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + // Ordinary qualifiers + Author: "johndoe", + Label: []string{"foo", "bar"}, + // Special qualifiers (that should be grouped and OR-ed when using advanced issue search) + Repo: []string{"foo/bar", "foo/baz"}, + Is: []string{"private", "public"}, + User: []string{"johndoe", "janedoe"}, + In: []string{"title", "body", "comments"}, + }, + } + + tests := []struct { + name string + query Query + detector fd.Detector + wantValues url.Values + wantErr string + }{ + { + name: "advanced issue search not supported", + detector: fd.AdvancedIssueSearchUnsupported(), + query: query, + wantValues: url.Values{ + "q": []string{"keyword author:johndoe in:body in:comments in:title is:private is:public label:bar label:foo repo:foo/bar repo:foo/baz user:janedoe user:johndoe"}, + "advanced_search": nil, // assert absence + }, + }, + { + name: "advanced issue search supported as an opt-in feature", + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + query: query, + wantValues: url.Values{ + "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)"}, + "advanced_search": []string{"true"}, // opt-in + }, + }, + { + name: "advanced issue search supported as the only search backend", + detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(), + query: query, + wantValues: url.Values{ + "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)"}, + "advanced_search": nil, // assert absence + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.QueryMatcher("GET", "search/issues", tt.wantValues), + httpmock.JSONResponse(IssuesResult{}), + ) + + client := &http.Client{Transport: reg} + searcher := NewSearcher(client, "github.com", tt.detector) + + _, err := searcher.Issues(tt.query) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestSearcherURL(t *testing.T) { query := Query{ Keywords: []string{"keyword"}, From f0a130dd4e7450d0c29970cc9b1843c605566bc7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 15:19:42 +0100 Subject: [PATCH 13/34] refactor(search): improve special qualifier grouping Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 36 +++++++++++++++++++++++++----------- pkg/search/query_test.go | 14 ++++++++++++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index fbe708938..b4e2beaf5 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -110,9 +110,9 @@ func (q Query) AdvancedIssueSearchString() string { func formatAdvancedIssueSearch(qualifier string, vs []string) (s []string, applicable bool) { switch qualifier { case "in": - return formatSpecialQualifiers("in", vs, []string{"title", "body", "comments"}), true + return formatSpecialQualifiers("in", vs, [][]string{{"title", "body", "comments"}}), true case "is": - return formatSpecialQualifiers("is", vs, []string{"private", "public"}), true + return formatSpecialQualifiers("is", vs, [][]string{{"blocked", "blocking"}, {"closed", "open"}, {"issue", "pr"}, {"locked", "unlocked"}, {"merged", "unmerged"}, {"private", "public"}}), true case "user", "repo": return []string{groupWithOR(qualifier, vs)}, true } @@ -120,21 +120,35 @@ func formatAdvancedIssueSearch(qualifier string, vs []string) (s []string, appli return nil, false } -func formatSpecialQualifiers(qualifier string, vs []string, valuesToOR []string) []string { - specials := make([]string, 0, len(vs)) +func formatSpecialQualifiers(qualifier string, vs []string, specialGroupsToOR [][]string) []string { + specialGroups := make([][]string, len(specialGroupsToOR)) rest := make([]string, 0, len(vs)) for _, v := range vs { - if slices.Contains(valuesToOR, v) { - specials = append(specials, v) - } else { - rest = append(rest, v) + var isSpecial bool + for i, subValuesToOR := range specialGroupsToOR { + if slices.Contains(subValuesToOR, v) { + specialGroups[i] = append(specialGroups[i], v) + isSpecial = true + break + } } + + if isSpecial { + continue + } + + rest = append(rest, v) } - all := make([]string, 0, 2) - if len(specials) > 0 { - all = append(all, groupWithOR(qualifier, specials)) + all := make([]string, 0, len(specialGroups)+len(rest)) + + for _, group := range specialGroups { + if len(group) == 0 { + continue + } + all = append(all, groupWithOR(qualifier, group)) } + if len(rest) > 0 { for _, v := range rest { all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v))) diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index f99f73178..330231465 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -126,12 +126,12 @@ func TestAdvancedIssueSearchString(t *testing.T) { Keywords: []string{"keyword"}, Qualifiers: Qualifiers{ Repo: []string{"foo/bar", "foo/baz"}, - Is: []string{"private", "public", "foo"}, // "foo" is to ensure only "public" and "private" are grouped + Is: []string{"private", "public", "issue", "pr", "open", "closed", "locked", "unlocked", "merged", "unmerged", "blocked", "blocking", "foo"}, // "foo" is to ensure only "public" and "private" are grouped User: []string{"johndoe", "janedoe"}, In: []string{"title", "body", "comments", "foo"}, // "foo" is to ensure only "title", "body", and "comments" are grouped }, }, - out: `keyword (in:body OR in:comments OR in:title) in:foo (is:private OR is:public) is:foo (repo:foo/bar OR repo:foo/baz) (user:janedoe OR user:johndoe)`, + 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)`, }, { name: "special qualifiers without special values", @@ -159,6 +159,16 @@ func TestAdvancedIssueSearchString(t *testing.T) { }, 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`, }, + { + name: "unused special qualifiers should be missing from the query", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + Label: []string{"foo", "bar"}, + }, + }, + out: `keyword label:bar label:foo`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 8ab6e722e2559a04543d9c9636ea9f8b82f886a6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 15:32:52 +0100 Subject: [PATCH 14/34] docs(search): improve docs for `Query.String` and `Query.AdvancedIssueSearchString` methods Signed-off-by: Babak K. Shandiz --- pkg/search/query.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/pkg/search/query.go b/pkg/search/query.go index b4e2beaf5..92eb1fcd6 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -87,8 +87,20 @@ type Qualifiers struct { } // String returns the string representation of the query which can be used with -// search API (except for advanced issue search), global search GUI (i.e. -// github.com/search), or Pull Requests tab (in repositories). +// the legacy search backend, which is used in global search GUI (i.e. +// github.com/search), or Pull Requests tab (in repositories). Note that this is +// a common query format that can be used to search for various entity types +// (e.g., issues, commits, repositories, etc) +// +// With the legacy search backend, the query is made of concatenating keywords +// and qualifiers with whitespaces. Note that at the backend side, most of the +// repeated qualifiers are AND-ed, while a handful of qualifiers (i.e. +// is:private/public, repo:, user:, or in:) are implicitly OR-ed. The legacy +// search backend does not support the advanced syntax which allows for nested +// queries and explicit OR operators. +// +// At the moment, the advanced search syntax is only available for searching +// issues, and it's called advanced issue search. func (q Query) String() string { qualifiers := formatQualifiers(q.Qualifiers, nil) keywords := formatKeywords(q.Keywords) @@ -100,6 +112,17 @@ func (q Query) String() string { // compatible with the advanced issue search syntax. The query can be used in // Issues tab (of repositories) and the Issues dashboard (i.e. // github.com/issues). +// +// As the name suggests, this query syntax is only supported for searching +// issues (i.e. issues and PRs). The advanced syntax allows nested queries and +// explicit OR operators. Unlike the legacy search backend, the advanced issue +// search does not OR repeated instances of special qualifiers (i.e. +// is:private/public, repo:, user:, or in:). +// +// To keep the gh experience consistent and backward-compatible, the mentioned +// special qualifiers are explicitly grouped and combined with an OR operator. +// +// The advanced syntax is documented at https://github.blog/changelog/2025-03-06-github-issues-projects-api-support-for-issues-advanced-search-and-more func (q Query) AdvancedIssueSearchString() string { qualifiers := formatQualifiers(q.Qualifiers, formatAdvancedIssueSearch) keywords := formatKeywords(q.Keywords) From 99daa74b002bdd1182d6f0e28a2ac549981a4e6b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 15:35:17 +0100 Subject: [PATCH 15/34] docs(search issues): mention advanced issue search takeover Signed-off-by: Babak K. Shandiz --- pkg/cmd/search/issues/issues.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 1d6ec6428..845cc7c60 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -35,6 +35,10 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c GitHub search syntax is documented at: + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, + the command performs advanced issue search. The advanced syntax is documented at: + + For more information on handling search queries containing a hyphen, run %[1]sgh search --help%[1]s. `, "`"), Example: heredoc.Doc(` From 3573fdfbb0fd763008f18c090ee9f6c6dc9ff4e4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 15:35:33 +0100 Subject: [PATCH 16/34] docs(search prs): mention advanced issue search takeover Signed-off-by: Babak K. Shandiz --- pkg/cmd/search/prs/prs.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index 98ab730b5..c91761f49 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -37,6 +37,10 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr GitHub search syntax is documented at: + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, + the command performs advanced issue search. The advanced syntax is documented at: + + For more information on handling search queries containing a hyphen, run %[1]sgh search --help%[1]s. `, "`"), Example: heredoc.Doc(` From 04cce6b35eeef38a722c78000a54ea60fac7c4e5 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 15:36:31 +0100 Subject: [PATCH 17/34] docs(search): improve `Searcher.URL` method docs Signed-off-by: Babak K. Shandiz --- pkg/search/searcher.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 6d0cccfc4..3fe69fd2a 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -263,11 +263,18 @@ func (s searcher) search(query Query, result interface{}) (*http.Response, error return resp, nil } +// URL returns URL to the global search in web GUI (i.e. github.com/search). func (s searcher) URL(query Query) string { path := fmt.Sprintf("https://%s/search", s.host) qs := url.Values{} qs.Set("type", query.Kind) + + // TODO(babakks): currently, the global search GUI does not support the + // advanced issue search syntax (even for the issues/PRs tab on the + // sidebar). When the GUI is updated, we can use feature detection, and, if + // available, use the advanced search syntax. qs.Set("q", query.String()) + if query.Order != "" { qs.Set(orderKey, query.Order) } From 6d148400a8c605247ffeaf5ca1e7e699414906c8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 18:15:56 +0100 Subject: [PATCH 18/34] refactor(issue/pr list): support advanced issue search Signed-off-by: Babak K. Shandiz --- pkg/cmd/issue/list/http.go | 29 +++++++++++++++------ pkg/cmd/issue/list/list.go | 15 ++++++++--- pkg/cmd/issue/list/list_test.go | 43 ++++++++++++++++++++------------ pkg/cmd/pr/list/http.go | 31 ++++++++++++++++++----- pkg/cmd/pr/list/http_test.go | 38 +++++++++++++++++----------- pkg/cmd/pr/list/list.go | 15 +++++++++-- pkg/cmd/pr/list/list_test.go | 29 ++++++++++++--------- pkg/cmd/pr/shared/params.go | 22 ++++++++++------ pkg/cmd/pr/shared/params_test.go | 7 +++--- 9 files changed, 158 insertions(+), 71 deletions(-) diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index fcbfe7240..eb2c54330 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" ) @@ -112,7 +113,12 @@ loop: return &res, nil } -func searchIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { +func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { + features, err := detector.SearchFeatures() + if err != nil { + return nil, err + } + fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.IssueGraphQL(filters.Fields)) query := fragments + `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { @@ -143,18 +149,27 @@ func searchIssues(client *api.Client, repo ghrepo.Interface, filters prShared.Fi } } - filters.Repo = ghrepo.FullName(repo) - filters.Entity = "issue" - q := prShared.SearchQueryBuild(filters) - perPage := min(limit, 100) variables := map[string]interface{}{ "owner": repo.RepoOwner(), "repo": repo.RepoName(), - "type": "ISSUE", "limit": perPage, - "query": q, + } + + filters.Repo = ghrepo.FullName(repo) + filters.Entity = "issue" + + if features.AdvancedIssueSearchAPI { + variables["query"] = prShared.SearchQueryBuild(filters, true) + if features.AdvancedIssueSearchAPIOptIn { + variables["type"] = "ISSUE_ADVANCED" + } else { + variables["type"] = "ISSUE" + } + } else { + variables["query"] = prShared.SearchQueryBuild(filters, false) + variables["type"] = "ISSUE" } ic := api.IssuesAndTotalCount{SearchCapped: limit > 1000} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 46c0e2cb0..241fbee02 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -165,8 +165,15 @@ func listRun(opts *ListOptions) error { isTerminal := opts.IO.IsStdoutTTY() if opts.WebMode { + searchFeatures, err := opts.Detector.SearchFeatures() + if err != nil { + return err + } + + advancedSyntaxSupported := searchFeatures.AdvancedIssueSearchAPI && searchFeatures.AdvancedIssueSearchWebInIssuesTab + issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions) + openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions, advancedSyntaxSupported) if err != nil { return err } @@ -181,7 +188,7 @@ func listRun(opts *ListOptions) error { filterOptions.Fields = opts.Exporter.Fields() } - listResult, err := issueList(httpClient, baseRepo, filterOptions, opts.LimitResults) + listResult, err := issueList(httpClient, opts.Detector, baseRepo, filterOptions, opts.LimitResults) if err != nil { return err } @@ -212,7 +219,7 @@ func listRun(opts *ListOptions) error { return nil } -func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { +func issueList(client *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { apiClient := api.NewClientFromHTTP(client) if filters.Search != "" || len(filters.Labels) > 0 || filters.Milestone != "" { @@ -224,7 +231,7 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt filters.Milestone = milestone.Title } - return searchIssues(apiClient, repo, filters, limit) + return searchIssues(apiClient, detector, repo, filters, limit) } var err error diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 852f0a46b..b88414d43 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" @@ -210,6 +211,7 @@ func TestIssueList_web(t *testing.T) { BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Detector: fd.AdvancedIssueSearchUnsupported(), WebMode: true, State: "all", Assignee: "peter", @@ -230,9 +232,10 @@ func TestIssueList_web(t *testing.T) { func Test_issueList(t *testing.T) { type args struct { - repo ghrepo.Interface - filters prShared.FilterOptions - limit int + detector fd.Detector + repo ghrepo.Interface + filters prShared.FilterOptions + limit int } tests := []struct { name string @@ -243,8 +246,9 @@ func Test_issueList(t *testing.T) { { name: "default", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -270,8 +274,9 @@ func Test_issueList(t *testing.T) { { name: "milestone by number", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -309,8 +314,9 @@ func Test_issueList(t *testing.T) { { name: "milestone by title", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -341,8 +347,9 @@ func Test_issueList(t *testing.T) { { name: "@me syntax", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -377,8 +384,9 @@ func Test_issueList(t *testing.T) { { name: "@me with search", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -412,8 +420,9 @@ func Test_issueList(t *testing.T) { { name: "with labels", args: args{ - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + detector: fd.AdvancedIssueSearchUnsupported(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -450,7 +459,7 @@ func Test_issueList(t *testing.T) { tt.httpStubs(httpreg) } client := &http.Client{Transport: httpreg} - _, err := issueList(client, tt.args.repo, tt.args.filters, tt.args.limit) + _, err := issueList(client, tt.args.detector, tt.args.repo, tt.args.filters, tt.args.limit) if tt.wantErr { assert.Error(t, err) } else { @@ -507,6 +516,7 @@ func TestIssueList_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} issuesAndTotalCount, err := issueList( client, + fd.AdvancedIssueSearchUnsupported(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "issue", @@ -581,6 +591,7 @@ func TestIssueList_Search_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} issuesAndTotalCount, err := issueList( client, + fd.AdvancedIssueSearchUnsupported(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "issue", diff --git a/pkg/cmd/pr/list/http.go b/pkg/cmd/pr/list/http.go index 4c69af708..24a303e43 100644 --- a/pkg/cmd/pr/list/http.go +++ b/pkg/cmd/pr/list/http.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" ) @@ -13,9 +14,9 @@ func shouldUseSearch(filters prShared.FilterOptions) bool { return filters.Draft != nil || filters.Author != "" || filters.Assignee != "" || filters.Search != "" || len(filters.Labels) > 0 } -func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { +func listPullRequests(httpClient *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { if shouldUseSearch(filters) { - return searchPullRequests(httpClient, repo, filters, limit) + return searchPullRequests(httpClient, detector, repo, filters, limit) } return prShared.NewLister(httpClient).List(prShared.ListOptions{ @@ -28,7 +29,12 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters pr }) } -func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { +func searchPullRequests(httpClient *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { + features, err := detector.SearchFeatures() + if err != nil { + return nil, err + } + type response struct { Search struct { Nodes []api.PullRequest @@ -44,10 +50,11 @@ func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters query := fragment + ` query PullRequestSearch( $q: String!, + $type: SearchType!, $limit: Int!, $endCursor: String, ) { - search(query: $q, type: ISSUE, first: $limit, after: $endCursor) { + search(query: $q, type: $type, first: $limit, after: $endCursor) { issueCount nodes { ...pr @@ -59,12 +66,24 @@ func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters } }` + variables := map[string]interface{}{} + filters.Repo = ghrepo.FullName(repo) filters.Entity = "pr" - q := prShared.SearchQueryBuild(filters) + + if features.AdvancedIssueSearchAPI { + variables["q"] = prShared.SearchQueryBuild(filters, true) + if features.AdvancedIssueSearchAPIOptIn { + variables["type"] = "ISSUE_ADVANCED" + } else { + variables["type"] = "ISSUE" + } + } else { + variables["q"] = prShared.SearchQueryBuild(filters, false) + variables["type"] = "ISSUE" + } pageLimit := min(limit, 100) - variables := map[string]interface{}{"q": q} res := api.PullRequestAndTotalCount{SearchCapped: limit > 1000} var check = make(map[int]struct{}) diff --git a/pkg/cmd/pr/list/http_test.go b/pkg/cmd/pr/list/http_test.go index 1aa16ae1d..4f796906a 100644 --- a/pkg/cmd/pr/list/http_test.go +++ b/pkg/cmd/pr/list/http_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/httpmock" @@ -12,9 +13,10 @@ import ( func Test_ListPullRequests(t *testing.T) { type args struct { - repo ghrepo.Interface - filters prShared.FilterOptions - limit int + detector fd.Detector + repo ghrepo.Interface + filters prShared.FilterOptions + limit int } tests := []struct { name string @@ -25,8 +27,9 @@ func Test_ListPullRequests(t *testing.T) { { name: "default", args: args{ - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + detector: fd.AdvancedIssueSearchUnsupported(), + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "open", }, @@ -50,8 +53,9 @@ func Test_ListPullRequests(t *testing.T) { { name: "closed", args: args{ - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + detector: fd.AdvancedIssueSearchUnsupported(), + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "closed", }, @@ -75,8 +79,9 @@ func Test_ListPullRequests(t *testing.T) { { name: "with labels", args: args{ - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + detector: fd.AdvancedIssueSearchUnsupported(), + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "open", Labels: []string{"hello", "one world"}, @@ -88,6 +93,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": `label:"one world" label:hello repo:OWNER/REPO state:open type:pr`, + "type": "ISSUE", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -99,8 +105,9 @@ func Test_ListPullRequests(t *testing.T) { { name: "with author", args: args{ - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + detector: fd.AdvancedIssueSearchUnsupported(), + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "open", Author: "monalisa", @@ -112,6 +119,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": "author:monalisa repo:OWNER/REPO state:open type:pr", + "type": "ISSUE", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -123,8 +131,9 @@ func Test_ListPullRequests(t *testing.T) { { name: "with search", args: args{ - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + detector: fd.AdvancedIssueSearchUnsupported(), + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "open", Search: "one world in:title", @@ -136,6 +145,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": "one world in:title repo:OWNER/REPO state:open type:pr", + "type": "ISSUE", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -153,7 +163,7 @@ func Test_ListPullRequests(t *testing.T) { } httpClient := &http.Client{Transport: reg} - _, err := listPullRequests(httpClient, tt.args.repo, tt.args.filters, tt.args.limit) + _, err := listPullRequests(httpClient, tt.args.detector, tt.args.repo, tt.args.filters, tt.args.limit) if (err != nil) != tt.wantErr { t.Errorf("ListPullRequests() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 7188df1a5..383e2b5fb 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" @@ -24,6 +25,7 @@ type ListOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Detector fd.Detector WebMode bool LimitResults int @@ -142,6 +144,11 @@ func listRun(opts *ListOptions) error { return err } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + prState := strings.ToLower(opts.State) if prState == "open" && shared.QueryHasStateClause(opts.Search) { prState = "" @@ -164,7 +171,11 @@ func listRun(opts *ListOptions) error { } if opts.WebMode { prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") - openURL, err := shared.ListURLWithQuery(prListURL, filters) + + // TODO(babakks): As of August 2025, the advanced issue search syntax is + // not supported in Pull Requests tab of repositories. When it's supported + // we can change the argument to true. + openURL, err := shared.ListURLWithQuery(prListURL, filters, false) if err != nil { return err } @@ -175,7 +186,7 @@ func listRun(opts *ListOptions) error { return opts.Browser.Browse(openURL) } - listResult, err := listPullRequests(httpClient, baseRepo, filters, opts.LimitResults) + listResult, err := listPullRequests(httpClient, opts.Detector, baseRepo, filters, opts.LimitResults) if err != nil { return err } diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index ecd0326b5..6a7c2815a 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -23,7 +24,7 @@ import ( "github.com/stretchr/testify/require" ) -func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { +func runCommand(rt http.RoundTripper, detector fd.Detector, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) ios.SetStdinTTY(isTTY) @@ -47,6 +48,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err cmd := NewCmdList(factory, func(opts *ListOptions) error { opts.Now = fakeNow + opts.Detector = detector return listRun(opts) }) @@ -78,7 +80,7 @@ func TestPRList(t *testing.T) { http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) - output, err := runCommand(http, true, "") + output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "") if err != nil { t.Fatal(err) } @@ -101,7 +103,7 @@ func TestPRList_nontty(t *testing.T) { http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) - output, err := runCommand(http, false, "") + output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), false, "") if err != nil { t.Fatal(err) } @@ -124,7 +126,7 @@ func TestPRList_filtering(t *testing.T) { assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) })) - output, err := runCommand(http, true, `-s all`) + output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s all`) assert.Error(t, err) assert.Equal(t, "", output.String()) @@ -139,7 +141,7 @@ func TestPRList_filteringRemoveDuplicate(t *testing.T) { httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prListWithDuplicates.json")) - output, err := runCommand(http, true, "") + output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "") if err != nil { t.Fatal(err) } @@ -162,7 +164,7 @@ func TestPRList_filteringClosed(t *testing.T) { assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) })) - _, err := runCommand(http, true, `-s closed`) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s closed`) assert.Error(t, err) } @@ -176,7 +178,7 @@ func TestPRList_filteringHeadBranch(t *testing.T) { assert.Equal(t, interface{}("bug-fix"), params["headBranch"]) })) - _, err := runCommand(http, true, `-H bug-fix`) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-H bug-fix`) assert.Error(t, err) } @@ -190,7 +192,7 @@ func TestPRList_filteringAssignee(t *testing.T) { assert.Equal(t, `assignee:hubot base:develop is:merged label:"needs tests" repo:OWNER/REPO type:pr`, params["q"].(string)) })) - _, err := runCommand(http, true, `-s merged -l "needs tests" -a hubot -B develop`) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s merged -l "needs tests" -a hubot -B develop`) assert.Error(t, err) } @@ -223,7 +225,7 @@ func TestPRList_filteringDraft(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) - _, err := runCommand(http, true, test.cli) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, test.cli) assert.Error(t, err) }) } @@ -268,7 +270,7 @@ func TestPRList_filteringAuthor(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) - _, err := runCommand(http, true, test.cli) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, test.cli) assert.Error(t, err) }) } @@ -277,7 +279,7 @@ func TestPRList_filteringAuthor(t *testing.T) { func TestPRList_withInvalidLimitFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - _, err := runCommand(http, true, `--limit=0`) + _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `--limit=0`) assert.EqualError(t, err, "invalid value for --limit: 0") } @@ -312,7 +314,7 @@ func TestPRList_web(t *testing.T) { _, cmdTeardown := run.Stub() defer cmdTeardown(t) - output, err := runCommand(http, true, "--web "+test.cli) + output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "--web "+test.cli) if err != nil { t.Errorf("error running command `pr list` with `--web` flag: %v", err) } @@ -370,6 +372,7 @@ func TestPRList_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} prsAndTotalCount, err := listPullRequests( client, + fd.AdvancedIssueSearchUnsupported(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "pr", @@ -433,12 +436,14 @@ func TestPRList_Search_withProjectItems(t *testing.T) { require.Equal(t, map[string]interface{}{ "limit": float64(30), "q": "just used to force the search API branch repo:OWNER/REPO state:open type:pr", + "type": "ISSUE", }, params) })) client := &http.Client{Transport: reg} prsAndTotalCount, err := listPullRequests( client, + fd.AdvancedIssueSearchUnsupported(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "pr", diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index c3315aeae..742b3795b 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -186,20 +186,20 @@ func (opts *FilterOptions) IsDefault() bool { return true } -func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { +func ListURLWithQuery(listURL string, options FilterOptions, advancedIssueSearchSyntax bool) (string, error) { u, err := url.Parse(listURL) if err != nil { return "", err } params := u.Query() - params.Set("q", SearchQueryBuild(options)) + params.Set("q", SearchQueryBuild(options, advancedIssueSearchSyntax)) u.RawQuery = params.Encode() return u.String(), nil } -func SearchQueryBuild(options FilterOptions) string { +func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) string { var is, state string switch options.State { case "open", "closed": @@ -207,7 +207,7 @@ func SearchQueryBuild(options FilterOptions) string { case "merged": is = "merged" } - q := search.Query{ + query := search.Query{ Qualifiers: search.Qualifiers{ Assignee: options.Assignee, Author: options.Author, @@ -223,10 +223,18 @@ func SearchQueryBuild(options FilterOptions) string { Type: options.Entity, }, } - if options.Search != "" { - return fmt.Sprintf("%s %s", options.Search, q.String()) + + var q string + if advancedIssueSearchSyntax { + q = query.AdvancedIssueSearchString() + } else { + q = query.String() } - return q.String() + + if options.Search != "" { + return fmt.Sprintf("%s %s", options.Search, q) + } + return q } func QueryHasStateClause(searchQuery string) bool { diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 3c95a9a5d..5cc73bc2f 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -19,8 +19,9 @@ func Test_listURLWithQuery(t *testing.T) { falseBool := false type args struct { - listURL string - options FilterOptions + listURL string + options FilterOptions + advancedIssueSearchSyntax bool } tests := []struct { @@ -101,7 +102,7 @@ func Test_listURLWithQuery(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ListURLWithQuery(tt.args.listURL, tt.args.options) + got, err := ListURLWithQuery(tt.args.listURL, tt.args.options, tt.args.advancedIssueSearchSyntax) if (err != nil) != tt.wantErr { t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr) return From 8a8b67ebc3b66ebc87c693a8b639a8dc3589fd1c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 18:50:26 +0100 Subject: [PATCH 19/34] test(pr shared): assert `ListURLWithQuery` works with advanced search syntax Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/params_test.go | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 5cc73bc2f..024965630 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -42,6 +42,19 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?a=b&q=state%3Aopen+type%3Aissue", wantErr: false, }, + { + name: "blank, advanced search", + args: args{ + listURL: "https://example.com/path?a=b", + options: FilterOptions{ + Entity: "issue", + State: "open", + }, + advancedIssueSearchSyntax: true, + }, + want: "https://example.com/path?a=b&q=state%3Aopen+type%3Aissue", + wantErr: false, + }, { name: "draft", args: args{ @@ -55,6 +68,20 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=draft%3Atrue+state%3Aopen+type%3Apr", wantErr: false, }, + { + name: "draft, advanced search", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Draft: &trueBool, + }, + advancedIssueSearchSyntax: true, + }, + want: "https://example.com/path?q=draft%3Atrue+state%3Aopen+type%3Apr", + wantErr: false, + }, { name: "non-draft", args: args{ @@ -68,6 +95,20 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=draft%3Afalse+state%3Aopen+type%3Apr", wantErr: false, }, + { + name: "non-draft, advanced search", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Draft: &falseBool, + }, + advancedIssueSearchSyntax: true, + }, + want: "https://example.com/path?q=draft%3Afalse+state%3Aopen+type%3Apr", + wantErr: false, + }, { name: "all", args: args{ @@ -85,6 +126,24 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=assignee%3Abo+author%3Aka+base%3Atrunk+head%3Abug-fix+mentions%3Anu+state%3Aopen+type%3Aissue", wantErr: false, }, + { + name: "all, advanced search", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + Assignee: "bo", + Author: "ka", + BaseBranch: "trunk", + HeadBranch: "bug-fix", + Mention: "nu", + }, + advancedIssueSearchSyntax: true, + }, + want: "https://example.com/path?q=assignee%3Abo+author%3Aka+base%3Atrunk+head%3Abug-fix+mentions%3Anu+state%3Aopen+type%3Aissue", + wantErr: false, + }, { name: "spaces in values", args: args{ @@ -99,6 +158,21 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=label%3A%22help+wanted%22+label%3Adocs+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22+state%3Aopen+type%3Apr", wantErr: false, }, + { + name: "spaces in values, advanced search", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Labels: []string{"docs", "help wanted"}, + Milestone: `Codename "What Was Missing"`, + }, + advancedIssueSearchSyntax: true, + }, + want: "https://example.com/path?q=label%3A%22help+wanted%22+label%3Adocs+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22+state%3Aopen+type%3Apr", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From b4213ac136aee9d7d2605c3ffe3bae2c45bc23aa Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 31 Aug 2025 21:11:55 +0100 Subject: [PATCH 20/34] test(issue/pr list): assert integration with advanced issue search Signed-off-by: Babak K. Shandiz --- pkg/cmd/issue/list/http_test.go | 47 ++++++++++++ pkg/cmd/issue/list/list_test.go | 124 +++++++++++++++++++------------- pkg/cmd/pr/list/http_test.go | 69 ++++++++++++++---- pkg/cmd/pr/list/list_test.go | 28 ++++---- 4 files changed, 192 insertions(+), 76 deletions(-) diff --git a/pkg/cmd/issue/list/http_test.go b/pkg/cmd/issue/list/http_test.go index a929746d1..8c73b7eac 100644 --- a/pkg/cmd/issue/list/http_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/httpmock" @@ -165,3 +166,49 @@ func TestIssueList_pagination(t *testing.T) { assert.Equal(t, []string{"enhancement"}, getLabels(res.Issues[1])) assert.Equal(t, []string{"user2"}, getAssignees(res.Issues[1])) } + +func TestSearchIssuesAndAdvancedSearch(t *testing.T) { + tests := []struct { + name string + detector fd.Detector + wantSearchType string + }{ + { + name: "advanced issue search not supported", + detector: fd.AdvancedIssueSearchUnsupported(), + wantSearchType: "ISSUE", + }, + { + name: "advanced issue search supported as opt-in", + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + wantSearchType: "ISSUE_ADVANCED", + }, + { + name: "advanced issue search supported as only backend", + detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(), + wantSearchType: "ISSUE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { + assert.Equal(t, tt.wantSearchType, vars["type"]) + // Since no repeated usage of special search qualifiers is possible + // with our current implementation, we can assert against the same + // query for both search backend (i.e. legacy and advanced issue search). + assert.Equal(t, "repo:OWNER/REPO state:open type:issue", vars["query"]) + })) + + httpClient := &http.Client{Transport: reg} + client := api.NewClientFromHTTP(httpClient) + + searchIssues(client, tt.detector, ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{State: "open"}, 30) + }) + } +} diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index b88414d43..040c38e8a 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -191,43 +191,69 @@ func TestIssueList_disabledIssues(t *testing.T) { } func TestIssueList_web(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - browser := &browser.Stub{} - - reg := &httpmock.Registry{} - defer reg.Verify(t) - - _, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - err := listRun(&ListOptions{ - IO: ios, - Browser: browser, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + tests := []struct { + name string + detector fd.Detector + }{ + { + name: "advanced issue search not supported", + detector: fd.AdvancedIssueSearchUnsupported(), }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + { + name: "advanced issue search supported as opt-in", + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + }, + { + name: "advanced issue search supported as only backend", + detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(), }, - Detector: fd.AdvancedIssueSearchUnsupported(), - WebMode: true, - State: "all", - Assignee: "peter", - Author: "john", - Labels: []string{"bug", "docs"}, - Mention: "frank", - Milestone: "v1.1", - LimitResults: 10, - }) - if err != nil { - t.Errorf("error running command `issue list` with `--web` flag: %v", err) } - assert.Equal(t, "", stdout.String()) - assert.Equal(t, "Opening https://github.com/OWNER/REPO/issues in your browser.\n", stderr.String()) - browser.Verify(t, "https://github.com/OWNER/REPO/issues?q=assignee%3Apeter+author%3Ajohn+label%3Abug+label%3Adocs+mentions%3Afrank+milestone%3Av1.1+type%3Aissue") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + browser := &browser.Stub{} + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + opts := &ListOptions{ + IO: ios, + Browser: browser, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: tt.detector, + WebMode: true, + State: "all", + Assignee: "peter", + Author: "john", + Labels: []string{"bug", "docs"}, + Mention: "frank", + Milestone: "v1.1", + LimitResults: 10, + } + + err := listRun(opts) + require.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "Opening https://github.com/OWNER/REPO/issues in your browser.\n", stderr.String()) + + // Since no repeated usage of special search qualifiers is possible + // with our current implementation, we can assert against the same + // URL for both search backend (i.e. legacy and advanced issue search). + browser.Verify(t, "https://github.com/OWNER/REPO/issues?q=assignee%3Apeter+author%3Ajohn+label%3Abug+label%3Adocs+mentions%3Afrank+milestone%3Av1.1+type%3Aissue") + }) + } } func Test_issueList(t *testing.T) { @@ -246,9 +272,8 @@ func Test_issueList(t *testing.T) { { name: "default", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -274,7 +299,7 @@ func Test_issueList(t *testing.T) { { name: "milestone by number", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ @@ -306,7 +331,7 @@ func Test_issueList(t *testing.T) { "repo": "REPO", "limit": float64(30), "query": "milestone:1.x repo:OWNER/REPO state:open type:issue", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", }, params) })) }, @@ -314,7 +339,7 @@ func Test_issueList(t *testing.T) { { name: "milestone by title", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ @@ -339,7 +364,7 @@ func Test_issueList(t *testing.T) { "repo": "REPO", "limit": float64(30), "query": "milestone:1.x repo:OWNER/REPO state:open type:issue", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", }, params) })) }, @@ -347,9 +372,8 @@ func Test_issueList(t *testing.T) { { name: "@me syntax", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), - limit: 30, - repo: ghrepo.New("OWNER", "REPO"), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ Entity: "issue", State: "open", @@ -384,7 +408,7 @@ func Test_issueList(t *testing.T) { { name: "@me with search", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ @@ -412,7 +436,7 @@ func Test_issueList(t *testing.T) { "repo": "REPO", "limit": float64(30), "query": "auth bug assignee:@me author:@me mentions:@me repo:OWNER/REPO state:open type:issue", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", }, params) })) }, @@ -420,7 +444,7 @@ func Test_issueList(t *testing.T) { { name: "with labels", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), filters: prShared.FilterOptions{ @@ -445,7 +469,7 @@ func Test_issueList(t *testing.T) { "repo": "REPO", "limit": float64(30), "query": `label:"one world" label:hello repo:OWNER/REPO state:open type:issue`, - "type": "ISSUE", + "type": "ISSUE_ADVANCED", }, params) })) }, @@ -516,7 +540,7 @@ func TestIssueList_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} issuesAndTotalCount, err := issueList( client, - fd.AdvancedIssueSearchUnsupported(), + nil, ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "issue", @@ -582,7 +606,7 @@ func TestIssueList_Search_withProjectItems(t *testing.T) { require.Equal(t, map[string]interface{}{ "owner": "OWNER", "repo": "REPO", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", "limit": float64(30), "query": "just used to force the search API branch repo:OWNER/REPO type:issue", }, params) @@ -591,7 +615,7 @@ func TestIssueList_Search_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} issuesAndTotalCount, err := issueList( client, - fd.AdvancedIssueSearchUnsupported(), + fd.AdvancedIssueSearchSupportedAsOptIn(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "issue", diff --git a/pkg/cmd/pr/list/http_test.go b/pkg/cmd/pr/list/http_test.go index 4f796906a..e3b8cad5d 100644 --- a/pkg/cmd/pr/list/http_test.go +++ b/pkg/cmd/pr/list/http_test.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" ) func Test_ListPullRequests(t *testing.T) { @@ -27,9 +28,8 @@ func Test_ListPullRequests(t *testing.T) { { name: "default", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "open", }, @@ -53,9 +53,8 @@ func Test_ListPullRequests(t *testing.T) { { name: "closed", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), - repo: ghrepo.New("OWNER", "REPO"), - limit: 30, + repo: ghrepo.New("OWNER", "REPO"), + limit: 30, filters: prShared.FilterOptions{ State: "closed", }, @@ -79,7 +78,7 @@ func Test_ListPullRequests(t *testing.T) { { name: "with labels", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, filters: prShared.FilterOptions{ @@ -93,7 +92,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": `label:"one world" label:hello repo:OWNER/REPO state:open type:pr`, - "type": "ISSUE", + "type": "ISSUE_ADVANCED", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -105,7 +104,7 @@ func Test_ListPullRequests(t *testing.T) { { name: "with author", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, filters: prShared.FilterOptions{ @@ -119,7 +118,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": "author:monalisa repo:OWNER/REPO state:open type:pr", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -131,7 +130,7 @@ func Test_ListPullRequests(t *testing.T) { { name: "with search", args: args{ - detector: fd.AdvancedIssueSearchUnsupported(), + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, filters: prShared.FilterOptions{ @@ -145,7 +144,7 @@ func Test_ListPullRequests(t *testing.T) { httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { want := map[string]interface{}{ "q": "one world in:title repo:OWNER/REPO state:open type:pr", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", "limit": float64(30), } if !reflect.DeepEqual(vars, want) { @@ -171,3 +170,49 @@ func Test_ListPullRequests(t *testing.T) { }) } } + +func TestSearchPullRequestsAndAdvancedSearch(t *testing.T) { + tests := []struct { + name string + detector fd.Detector + wantSearchType string + }{ + { + name: "advanced issue search not supported", + detector: fd.AdvancedIssueSearchUnsupported(), + wantSearchType: "ISSUE", + }, + { + name: "advanced issue search supported as opt-in", + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + wantSearchType: "ISSUE_ADVANCED", + }, + { + name: "advanced issue search supported as only backend", + detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(), + wantSearchType: "ISSUE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`query PullRequestSearch\b`), + httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) { + assert.Equal(t, tt.wantSearchType, vars["type"]) + + // Since no repeated usage of special search qualifiers is possible + // with our current implementation, we can assert against the same + // query for both search backend (i.e. legacy and advanced issue search). + assert.Equal(t, "repo:OWNER/REPO state:open type:pr", vars["q"]) + })) + + httpClient := &http.Client{Transport: reg} + + searchPullRequests(httpClient, tt.detector, ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{State: "open"}, 30) + }) + } +} diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 6a7c2815a..3df8721e6 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -80,7 +80,7 @@ func TestPRList(t *testing.T) { http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) - output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "") + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatal(err) } @@ -103,7 +103,7 @@ func TestPRList_nontty(t *testing.T) { http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) - output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), false, "") + output, err := runCommand(http, nil, false, "") if err != nil { t.Fatal(err) } @@ -126,7 +126,7 @@ func TestPRList_filtering(t *testing.T) { assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) })) - output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s all`) + output, err := runCommand(http, nil, true, `-s all`) assert.Error(t, err) assert.Equal(t, "", output.String()) @@ -141,7 +141,7 @@ func TestPRList_filteringRemoveDuplicate(t *testing.T) { httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prListWithDuplicates.json")) - output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "") + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatal(err) } @@ -164,7 +164,7 @@ func TestPRList_filteringClosed(t *testing.T) { assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) })) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s closed`) + _, err := runCommand(http, nil, true, `-s closed`) assert.Error(t, err) } @@ -178,7 +178,7 @@ func TestPRList_filteringHeadBranch(t *testing.T) { assert.Equal(t, interface{}("bug-fix"), params["headBranch"]) })) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-H bug-fix`) + _, err := runCommand(http, nil, true, `-H bug-fix`) assert.Error(t, err) } @@ -192,7 +192,7 @@ func TestPRList_filteringAssignee(t *testing.T) { assert.Equal(t, `assignee:hubot base:develop is:merged label:"needs tests" repo:OWNER/REPO type:pr`, params["q"].(string)) })) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `-s merged -l "needs tests" -a hubot -B develop`) + _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, `-s merged -l "needs tests" -a hubot -B develop`) assert.Error(t, err) } @@ -225,7 +225,7 @@ func TestPRList_filteringDraft(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, test.cli) + _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, test.cli) assert.Error(t, err) }) } @@ -270,7 +270,7 @@ func TestPRList_filteringAuthor(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, test.cli) + _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, test.cli) assert.Error(t, err) }) } @@ -279,7 +279,7 @@ func TestPRList_filteringAuthor(t *testing.T) { func TestPRList_withInvalidLimitFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - _, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, `--limit=0`) + _, err := runCommand(http, nil, true, `--limit=0`) assert.EqualError(t, err, "invalid value for --limit: 0") } @@ -314,7 +314,7 @@ func TestPRList_web(t *testing.T) { _, cmdTeardown := run.Stub() defer cmdTeardown(t) - output, err := runCommand(http, fd.AdvancedIssueSearchUnsupported(), true, "--web "+test.cli) + output, err := runCommand(http, nil, true, "--web "+test.cli) if err != nil { t.Errorf("error running command `pr list` with `--web` flag: %v", err) } @@ -372,7 +372,7 @@ func TestPRList_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} prsAndTotalCount, err := listPullRequests( client, - fd.AdvancedIssueSearchUnsupported(), + nil, ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "pr", @@ -436,14 +436,14 @@ func TestPRList_Search_withProjectItems(t *testing.T) { require.Equal(t, map[string]interface{}{ "limit": float64(30), "q": "just used to force the search API branch repo:OWNER/REPO state:open type:pr", - "type": "ISSUE", + "type": "ISSUE_ADVANCED", }, params) })) client := &http.Client{Transport: reg} prsAndTotalCount, err := listPullRequests( client, - fd.AdvancedIssueSearchUnsupported(), + fd.AdvancedIssueSearchSupportedAsOptIn(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ Entity: "pr", From 5747297775c34ffa1a214822e0c748e6bc50e968 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 1 Sep 2025 13:40:20 +0100 Subject: [PATCH 21/34] docs(issue list): explain use of advanced issue search syntax Signed-off-by: Babak K. Shandiz --- pkg/cmd/issue/list/list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 241fbee02..2dea7dceb 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -61,6 +61,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Long: heredoc.Doc(` List issues in a GitHub repository. By default, this only lists open issues. + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, + the command performs search by using the advanced issue search syntax, which + is documented at: + + The search query syntax is documented here: `), From 33f1f6ea68414089f8f0b3ac252c63011632f306 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 1 Sep 2025 13:40:46 +0100 Subject: [PATCH 22/34] docs(pr list): explain use of advanced issue search syntax Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/list/list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 383e2b5fb..1419aef9d 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -59,6 +59,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Long: heredoc.Doc(` List pull requests in a GitHub repository. By default, this only lists open PRs. + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, + the command performs search by using the advanced issue search syntax, which + is documented at: + + The search query syntax is documented here: `), From 87bd76c5aa2f55b18acedb40883fe4663a6faa74 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 1 Sep 2025 13:55:44 +0100 Subject: [PATCH 23/34] docs: add cleanup/future TODO marks for advanced issue search changes Signed-off-by: Babak K. Shandiz --- .../featuredetection/feature_detection.go | 21 ++++++++++++------- pkg/cmd/issue/list/http.go | 4 ++++ pkg/cmd/issue/list/http_test.go | 2 ++ pkg/cmd/issue/list/list.go | 7 +++++++ pkg/cmd/issue/list/list_test.go | 12 +++++++++++ pkg/cmd/pr/list/http.go | 4 ++++ pkg/cmd/pr/list/http_test.go | 8 +++++++ pkg/cmd/pr/list/list.go | 9 +++++--- pkg/cmd/pr/list/list_test.go | 8 +++++++ pkg/cmd/search/issues/issues.go | 2 ++ pkg/cmd/search/prs/prs.go | 2 ++ pkg/search/searcher.go | 13 ++++++++---- pkg/search/searcher_test.go | 6 ++++++ 13 files changed, 83 insertions(+), 15 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index ad1a0c092..00f9fbb91 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -68,9 +68,9 @@ type SearchFeatures struct { // advanced issue search syntax in the Issues tab of repositories. AdvancedIssueSearchWebInIssuesTab bool - // TODO(babakks): when advanced issue search is supported in Pull Requests - // tab, or in global search we can introduce more fields to reflect the - // support status + // TODO advancedSearchFuture + // When advanced issue search is supported in Pull Requests tab, or in + // global search we can introduce more fields to reflect the support status. } // advancedIssueSearchNotSupported mimics GHE <3.18 where advanced issue search @@ -273,6 +273,9 @@ const ( ) func (d *detector) SearchFeatures() (SearchFeatures, error) { + // TODO advancedIssueSearchCleanup + // Once GHES 3.17 support ends, we don't need this and, probably, the entire search feature detection. + // Regarding the release of advanced issue search (AIS, for short), there // are three time spans/periods: // @@ -310,9 +313,10 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { feature.AdvancedIssueSearchAPI = true feature.AdvancedIssueSearchWebInIssuesTab = true - // TODO(babakks): when the advanced search syntax is supported in - // global search or Pull Requests tabs (in repositories), we can - // enable the corresponding fields. + // TODO advancedSearchFuture + // When the advanced search syntax is supported in global search or + // Pull Requests tabs (in repositories), we can add and enable the + // corresponding fields. } } else { // As of August 2025, advanced issue search is available on github.com, @@ -320,8 +324,9 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { feature.AdvancedIssueSearchAPI = true feature.AdvancedIssueSearchWebInIssuesTab = true - // TODO(babakks): when the advanced search syntax is supported in global - // search or Pull Requests tabs (in repositories), we can enable the + // TODO advancedSearchFuture + // When the advanced search syntax is supported in global search or + // Pull Requests tabs (in repositories), we can add and enable the // corresponding fields. } diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index eb2c54330..0fceb3e97 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -114,6 +114,10 @@ loop: } func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { + // TODO advancedIssueSearchCleanup + // We won't need feature detection when GHES 3.17 support ends, since + // the advanced issue search is the only available search backend for + // issues. features, err := detector.SearchFeatures() if err != nil { return nil, err diff --git a/pkg/cmd/issue/list/http_test.go b/pkg/cmd/issue/list/http_test.go index 8c73b7eac..747bd0a4b 100644 --- a/pkg/cmd/issue/list/http_test.go +++ b/pkg/cmd/issue/list/http_test.go @@ -167,6 +167,8 @@ func TestIssueList_pagination(t *testing.T) { assert.Equal(t, []string{"user2"}, getAssignees(res.Issues[1])) } +// TODO advancedIssueSearchCleanup +// Remove this test once GHES 3.17 support ends. func TestSearchIssuesAndAdvancedSearch(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 2dea7dceb..c27374533 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -58,6 +58,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", Short: "List issues in a repository", + // TODO advancedIssueSearchCleanup + // Update the links and remove the mention at GHES 3.17 version. Long: heredoc.Doc(` List issues in a GitHub repository. By default, this only lists open issues. @@ -170,6 +172,11 @@ func listRun(opts *ListOptions) error { isTerminal := opts.IO.IsStdoutTTY() if opts.WebMode { + // TODO advancedIssueSearchCleanup + // We won't need feature detection when GHES 3.17 support ends, since + // the advanced issue search is the only available search backend for + // issues, and the GUI (i.e. Issues tab of repos) already supports the + // advanced syntax. searchFeatures, err := opts.Detector.SearchFeatures() if err != nil { return err diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 040c38e8a..83dc313ce 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -190,6 +190,8 @@ func TestIssueList_disabledIssues(t *testing.T) { } } +// TODO advancedIssueSearchCleanup +// Simplify this test to only a single test case once GHES 3.17 support ends. func TestIssueList_web(t *testing.T) { tests := []struct { name string @@ -299,6 +301,8 @@ func Test_issueList(t *testing.T) { { name: "milestone by number", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), @@ -339,6 +343,8 @@ func Test_issueList(t *testing.T) { { name: "milestone by title", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), @@ -408,6 +414,8 @@ func Test_issueList(t *testing.T) { { name: "@me with search", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), @@ -444,6 +452,8 @@ func Test_issueList(t *testing.T) { { name: "with labels", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), limit: 30, repo: ghrepo.New("OWNER", "REPO"), @@ -615,6 +625,8 @@ func TestIssueList_Search_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} issuesAndTotalCount, err := issueList( client, + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. fd.AdvancedIssueSearchSupportedAsOptIn(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ diff --git a/pkg/cmd/pr/list/http.go b/pkg/cmd/pr/list/http.go index 24a303e43..4f82d5006 100644 --- a/pkg/cmd/pr/list/http.go +++ b/pkg/cmd/pr/list/http.go @@ -30,6 +30,10 @@ func listPullRequests(httpClient *http.Client, detector fd.Detector, repo ghrepo } func searchPullRequests(httpClient *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) { + // TODO advancedIssueSearchCleanup + // We won't need feature detection when GHES 3.17 support ends, since + // the advanced issue search is the only available search backend for + // issues. features, err := detector.SearchFeatures() if err != nil { return nil, err diff --git a/pkg/cmd/pr/list/http_test.go b/pkg/cmd/pr/list/http_test.go index e3b8cad5d..5f08498f7 100644 --- a/pkg/cmd/pr/list/http_test.go +++ b/pkg/cmd/pr/list/http_test.go @@ -78,6 +78,8 @@ func Test_ListPullRequests(t *testing.T) { { name: "with labels", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, @@ -104,6 +106,8 @@ func Test_ListPullRequests(t *testing.T) { { name: "with author", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, @@ -130,6 +134,8 @@ func Test_ListPullRequests(t *testing.T) { { name: "with search", args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. detector: fd.AdvancedIssueSearchSupportedAsOptIn(), repo: ghrepo.New("OWNER", "REPO"), limit: 30, @@ -171,6 +177,8 @@ func Test_ListPullRequests(t *testing.T) { } } +// TODO advancedIssueSearchCleanup +// Remove this test once GHES 3.17 support ends. func TestSearchPullRequestsAndAdvancedSearch(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 1419aef9d..8e927cafa 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -56,6 +56,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", Short: "List pull requests in a repository", + // TODO advancedIssueSearchCleanup + // Update the links and remove the mention at GHES 3.17 version. Long: heredoc.Doc(` List pull requests in a GitHub repository. By default, this only lists open PRs. @@ -177,9 +179,10 @@ func listRun(opts *ListOptions) error { if opts.WebMode { prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") - // TODO(babakks): As of August 2025, the advanced issue search syntax is - // not supported in Pull Requests tab of repositories. When it's supported - // we can change the argument to true. + // TODO advancedSearchFuture + // As of August 2025, the advanced issue search syntax is not supported + // in Pull Requests tab of repositories. When it's supported we can + // change the argument to true. openURL, err := shared.ListURLWithQuery(prListURL, filters, false) if err != nil { return err diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 3df8721e6..4953b8888 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -192,6 +192,8 @@ func TestPRList_filteringAssignee(t *testing.T) { assert.Equal(t, `assignee:hubot base:develop is:merged label:"needs tests" repo:OWNER/REPO type:pr`, params["q"].(string)) })) + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, `-s merged -l "needs tests" -a hubot -B develop`) assert.Error(t, err) } @@ -225,6 +227,8 @@ func TestPRList_filteringDraft(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, test.cli) assert.Error(t, err) }) @@ -270,6 +274,8 @@ func TestPRList_filteringAuthor(t *testing.T) { assert.Equal(t, test.expectedQuery, params["q"].(string)) })) + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. _, err := runCommand(http, fd.AdvancedIssueSearchSupportedAsOptIn(), true, test.cli) assert.Error(t, err) }) @@ -443,6 +449,8 @@ func TestPRList_Search_withProjectItems(t *testing.T) { client := &http.Client{Transport: reg} prsAndTotalCount, err := listPullRequests( client, + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. fd.AdvancedIssueSearchSupportedAsOptIn(), ghrepo.New("OWNER", "REPO"), prShared.FilterOptions{ diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 845cc7c60..a4bdca36a 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -26,6 +26,8 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c cmd := &cobra.Command{ Use: "issues []", Short: "Search for issues", + // TODO advancedIssueSearchCleanup + // Update the links and remove the mention at GHES 3.17 version. Long: heredoc.Docf(` Search for issues on GitHub. diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index c91761f49..db1731053 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -28,6 +28,8 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr cmd := &cobra.Command{ Use: "prs []", Short: "Search for pull requests", + // TODO advancedIssueSearchCleanup + // Update the links and remove the mention at GHES 3.17 version. Long: heredoc.Docf(` Search for pull requests on GitHub. diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 3fe69fd2a..7097d2fe8 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -210,6 +210,10 @@ func (s searcher) search(query Query, result interface{}) (*http.Response, error qs.Set("per_page", strconv.Itoa(query.Limit)) if query.Kind == KindIssues { + // TODO advancedIssueSearchCleanup + // We won't need feature detection when GHES 3.17 support ends, since + // the advanced issue search is the only available search backend for + // issues. features, err := s.detector.SearchFeatures() if err != nil { return nil, err @@ -269,10 +273,11 @@ func (s searcher) URL(query Query) string { qs := url.Values{} qs.Set("type", query.Kind) - // TODO(babakks): currently, the global search GUI does not support the - // advanced issue search syntax (even for the issues/PRs tab on the - // sidebar). When the GUI is updated, we can use feature detection, and, if - // available, use the advanced search syntax. + // TODO advancedSearchFuture + // Currently, the global search GUI does not support the advanced issue + // search syntax (even for the issues/PRs tab on the sidebar). When the GUI + // is updated, we can use feature detection, and, if available, use the + // advanced search syntax. qs.Set("q", query.String()) if query.Order != "" { diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index df30ae6ac..f5467be37 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1161,6 +1161,8 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) { wantErr string }{ { + // TODO advancedIssueSearchCleanup + // Remove this test case once GHES 3.17 support ends. name: "advanced issue search not supported", detector: fd.AdvancedIssueSearchUnsupported(), query: query, @@ -1170,6 +1172,8 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) { }, }, { + // TODO advancedIssueSearchCleanup + // Remove this test case once GHES 3.17 support ends. name: "advanced issue search supported as an opt-in feature", detector: fd.AdvancedIssueSearchSupportedAsOptIn(), query: query, @@ -1179,6 +1183,8 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) { }, }, { + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. name: "advanced issue search supported as the only search backend", detector: fd.AdvancedIssueSearchSupportedAsOnlyBackend(), query: query, From 20e8b9e8ea66146599aacf19e3f71178ecaef286 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 2 Sep 2025 10:14:32 +0100 Subject: [PATCH 24/34] docs(issue list): fix incorrect formatting Signed-off-by: Babak K. Shandiz --- pkg/cmd/issue/list/list.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index c27374533..cb88768f1 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -60,17 +60,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List issues in a repository", // TODO advancedIssueSearchCleanup // Update the links and remove the mention at GHES 3.17 version. - Long: heredoc.Doc(` + Long: heredoc.Docf(` List issues in a GitHub repository. By default, this only lists open issues. - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, - the command performs search by using the advanced issue search syntax, which - is documented at: + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, the command + performs search by using the advanced issue search syntax, which is documented at: The search query syntax is documented here: - `), + `, "`"), Example: heredoc.Doc(` $ gh issue list --label "bug" --label "help wanted" $ gh issue list --author monalisa From 7b4ace9f5426d79731129c6a32d472cbbef38324 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 2 Sep 2025 10:14:59 +0100 Subject: [PATCH 25/34] docs(issue pr): fix incorrect formatting Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/list/list.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 8e927cafa..82802ef5e 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -58,17 +58,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List pull requests in a repository", // TODO advancedIssueSearchCleanup // Update the links and remove the mention at GHES 3.17 version. - Long: heredoc.Doc(` + Long: heredoc.Docf(` List pull requests in a GitHub repository. By default, this only lists open PRs. - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, - the command performs search by using the advanced issue search syntax, which - is documented at: + When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, the command + performs search by using the advanced issue search syntax, which is documented at: The search query syntax is documented here: - `), + `, "`"), Example: heredoc.Doc(` # List PRs authored by you $ gh pr list --author "@me" From d56a902a075b2c6ac86d03d047a543fe18dbc6ec Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 2 Sep 2025 12:09:38 +0100 Subject: [PATCH 26/34] docs(featuredetection): add godoc for min GHES version for advanced issue search Signed-off-by: Babak K. Shandiz --- internal/featuredetection/feature_detection.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 00f9fbb91..3b495e988 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -269,6 +269,12 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support { } const ( + // enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that + // supports advanced issue search and gh should use it. + // + // Note that advanced issue search is also available on GHES 3.17, but it's + // at the preview stage and is not as mature as it is on github.com or later + // GHES version. enterpriseAdvancedIssueSearchSupport = "3.18.0" ) From efddea720fe9de59f37fb95d474e60e28f118e43 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 15:45:20 +0100 Subject: [PATCH 27/34] test(search): improve test cases Signed-off-by: Babak K. Shandiz --- pkg/search/query_test.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 330231465..457c17b86 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -107,6 +107,16 @@ func TestAdvancedIssueSearchString(t *testing.T) { }, out: `label:"quote qualifier"`, }, + { + name: "unused qualifiers should not appear in query", + query: Query{ + Keywords: []string{"keyword"}, + Qualifiers: Qualifiers{ + Label: []string{"foo", "bar"}, + }, + }, + out: `keyword label:bar label:foo`, + }, { name: "special qualifiers when used once", query: Query{ @@ -134,6 +144,9 @@ func TestAdvancedIssueSearchString(t *testing.T) { 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)`, }, { + // Since this is a general purpose package, we can't assume with know all + // use cases of special qualifiers. So, here we ensure unknown values are + // not OR-ed by default. name: "special qualifiers without special values", query: Query{ Keywords: []string{"keyword"}, @@ -159,16 +172,6 @@ func TestAdvancedIssueSearchString(t *testing.T) { }, 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`, }, - { - name: "unused special qualifiers should be missing from the query", - query: Query{ - Keywords: []string{"keyword"}, - Qualifiers: Qualifiers{ - Label: []string{"foo", "bar"}, - }, - }, - out: `keyword label:bar label:foo`, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From ef69901f052ebd5a4c3d2ee9360e16e26e9c524f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 16:04:47 +0100 Subject: [PATCH 28/34] refactor(search): rename `Query.String` to `StandardSearchString` Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/params.go | 2 +- pkg/cmd/repo/list/http.go | 2 +- pkg/search/query.go | 2 +- pkg/search/query_test.go | 4 ++-- pkg/search/searcher.go | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 742b3795b..9504ecef4 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -228,7 +228,7 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str if advancedIssueSearchSyntax { q = query.AdvancedIssueSearchString() } else { - q = query.String() + q = query.StandardSearchString() } if options.Search != "" { diff --git a/pkg/cmd/repo/list/http.go b/pkg/cmd/repo/list/http.go index 0508c6d80..d896c9224 100644 --- a/pkg/cmd/repo/list/http.go +++ b/pkg/cmd/repo/list/http.go @@ -213,5 +213,5 @@ func searchQuery(owner string, filter FilterOptions) string { }, } - return q.String() + return q.StandardSearchString() } diff --git a/pkg/search/query.go b/pkg/search/query.go index 92eb1fcd6..0419e359a 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -101,7 +101,7 @@ type Qualifiers struct { // // At the moment, the advanced search syntax is only available for searching // issues, and it's called advanced issue search. -func (q Query) String() string { +func (q Query) StandardSearchString() string { qualifiers := formatQualifiers(q.Qualifiers, nil) keywords := formatKeywords(q.Keywords) all := append(keywords, qualifiers...) diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index 457c17b86..7c20e5348 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -8,7 +8,7 @@ import ( var trueBool = true -func TestQueryString(t *testing.T) { +func TestStandardSearchString(t *testing.T) { tests := []struct { name string query Query @@ -73,7 +73,7 @@ func TestQueryString(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.out, tt.query.String()) + assert.Equal(t, tt.out, tt.query.StandardSearchString()) }) } } diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 7097d2fe8..5cc94ac9e 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -220,7 +220,7 @@ func (s searcher) search(query Query, result interface{}) (*http.Response, error } if !features.AdvancedIssueSearchAPI { - qs.Set("q", query.String()) + qs.Set("q", query.StandardSearchString()) } else { qs.Set("q", query.AdvancedIssueSearchString()) @@ -230,7 +230,7 @@ func (s searcher) search(query Query, result interface{}) (*http.Response, error } } } else { - qs.Set("q", query.String()) + qs.Set("q", query.StandardSearchString()) } if query.Order != "" { @@ -278,7 +278,7 @@ func (s searcher) URL(query Query) string { // search syntax (even for the issues/PRs tab on the sidebar). When the GUI // is updated, we can use feature detection, and, if available, use the // advanced search syntax. - qs.Set("q", query.String()) + qs.Set("q", query.StandardSearchString()) if query.Order != "" { qs.Set(orderKey, query.Order) From 02ee33781849b66dab80050b46b2aa90523a7304 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:36:24 +0100 Subject: [PATCH 29/34] docs(issue list): mention advanced issue search syntax support Signed-off-by: Babak K. Shandiz --- pkg/cmd/issue/list/list.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index cb88768f1..171b0e171 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -63,12 +63,12 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Long: heredoc.Docf(` List issues in a GitHub repository. By default, this only lists open issues. - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, the command - performs search by using the advanced issue search syntax, which is documented at: - - The search query syntax is documented here: + + On supported GitHub hosts, advanced issue search syntax can be used in the + %[1]s--search%[1]s query. For more information about advanced issue search, see: + `, "`"), Example: heredoc.Doc(` $ gh issue list --label "bug" --label "help wanted" From 02dc03c8e2b29fae95c0b9f703e9937ab8452560 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:36:46 +0100 Subject: [PATCH 30/34] docs(pr list): mention advanced issue search syntax support Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/list/list.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 82802ef5e..a7e40384a 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -61,12 +61,12 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Long: heredoc.Docf(` List pull requests in a GitHub repository. By default, this only lists open PRs. - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, the command - performs search by using the advanced issue search syntax, which is documented at: - - The search query syntax is documented here: + + On supported GitHub hosts, advanced issue search syntax can be used in the + %[1]s--search%[1]s query. For more information about advanced issue search, see: + `, "`"), Example: heredoc.Doc(` # List PRs authored by you From cc60d9c3fad21dbe671ccbe62ccf1c6646a21877 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:37:17 +0100 Subject: [PATCH 31/34] docs(search issues): mention advanced issue search syntax support Signed-off-by: Babak K. Shandiz --- pkg/cmd/search/issues/issues.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index a4bdca36a..e616430fb 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -37,9 +37,9 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c GitHub search syntax is documented at: - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, - the command performs advanced issue search. The advanced syntax is documented at: - + On supported GitHub hosts, advanced issue search syntax can be used in the + %[1]s--search%[1]s query. For more information about advanced issue search, see: + For more information on handling search queries containing a hyphen, run %[1]sgh search --help%[1]s. `, "`"), From 2c08f20c33d6fcd04ab60e753cd7d306cf810489 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:37:37 +0100 Subject: [PATCH 32/34] docs(search prs): mention advanced issue search syntax support Signed-off-by: Babak K. Shandiz --- pkg/cmd/search/prs/prs.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index db1731053..498569863 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -39,9 +39,9 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr GitHub search syntax is documented at: - When runs against %[1]sgithub.com%[1]s or GHE versions newer than 3.17.x, - the command performs advanced issue search. The advanced syntax is documented at: - + On supported GitHub hosts, advanced issue search syntax can be used in the + %[1]s--search%[1]s query. For more information about advanced issue search, see: + For more information on handling search queries containing a hyphen, run %[1]sgh search --help%[1]s. `, "`"), From 43bedab2dc029285ee77f2fbd6b686ebd93b31c8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:39:55 +0100 Subject: [PATCH 33/34] docs(featuredetection): remove unknown dates Signed-off-by: Babak K. Shandiz --- internal/featuredetection/feature_detection.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 3b495e988..dd845c795 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -285,13 +285,13 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { // Regarding the release of advanced issue search (AIS, for short), there // are three time spans/periods: // - // 1. Pre-deprecation (< 4 Sep): where both legacy search and AIS are available + // 1. Pre-deprecation: where both legacy search and AIS are available // - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave differently // - REST: `advance_search=true` query parameter can be used to switch to AIS - // 2. Deprecation (>= 4 Sep): only AIS available + // 2. Deprecation: only AIS available // - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave the same (AIS) // - REST: `advance_search` query parameter has no effect (AIS) - // 3. Cleanup (>= TBD): only AIS available + // 3. Cleanup: only AIS available // - GraphQL: `ISSUE` search type in GraphQL is the only available option (AIS) // - REST: `advance_search` query parameter has no effect (AIS) // From 37896d613a3a204bdda7013682bc542f7994c999 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 18:46:28 +0100 Subject: [PATCH 34/34] fix(featuredetection): remove redundant `AdvancedIssueSearchWebInIssuesTab` field Signed-off-by: Babak K. Shandiz --- internal/featuredetection/feature_detection.go | 15 ++++----------- pkg/cmd/issue/list/list.go | 7 ++++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index dd845c795..786093d93 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -64,9 +64,6 @@ type SearchFeatures struct { // issue search as an opt-in feature, which has to be explicitly enabled in // API calls. AdvancedIssueSearchAPIOptIn bool - // AdvancedIssueSearchWebInIssuesTab indicates whether the host supports - // advanced issue search syntax in the Issues tab of repositories. - AdvancedIssueSearchWebInIssuesTab bool // TODO advancedSearchFuture // When advanced issue search is supported in Pull Requests tab, or in @@ -84,18 +81,16 @@ var advancedIssueSearchNotSupported = SearchFeatures{ // the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is still // present on the schema). var advancedIssueSearchSupportedAsOptIn = SearchFeatures{ - AdvancedIssueSearchAPI: true, - AdvancedIssueSearchAPIOptIn: true, - AdvancedIssueSearchWebInIssuesTab: true, + AdvancedIssueSearchAPI: true, + AdvancedIssueSearchAPIOptIn: true, } // advancedIssueSearchSupportedAsOnlyBackend mimics github.com and GHE >=3.18 // after the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is // removed from the schema). var advancedIssueSearchSupportedAsOnlyBackend = SearchFeatures{ - AdvancedIssueSearchAPI: true, - AdvancedIssueSearchAPIOptIn: false, - AdvancedIssueSearchWebInIssuesTab: true, + AdvancedIssueSearchAPI: true, + AdvancedIssueSearchAPIOptIn: false, } type detector struct { @@ -317,7 +312,6 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { // As of August 2025, advanced issue search is going to be available // on GHES 3.18+, including Issues tabs in repositories. feature.AdvancedIssueSearchAPI = true - feature.AdvancedIssueSearchWebInIssuesTab = true // TODO advancedSearchFuture // When the advanced search syntax is supported in global search or @@ -328,7 +322,6 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { // As of August 2025, advanced issue search is available on github.com, // including Issues tabs in repositories. feature.AdvancedIssueSearchAPI = true - feature.AdvancedIssueSearchWebInIssuesTab = true // TODO advancedSearchFuture // When the advanced search syntax is supported in global search or diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 171b0e171..9a6495ec2 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -181,10 +181,11 @@ func listRun(opts *ListOptions) error { return err } - advancedSyntaxSupported := searchFeatures.AdvancedIssueSearchAPI && searchFeatures.AdvancedIssueSearchWebInIssuesTab - issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions, advancedSyntaxSupported) + + // Note that if the advanced issue search API is available, the syntax is + // also supported in the Issues tab. + openURL, err := prShared.ListURLWithQuery(issueListURL, filterOptions, searchFeatures.AdvancedIssueSearchAPI) if err != nil { return err }