Skip to content

Commit 6b19a85

Browse files
authored
Merge pull request cli#11638 from cli/babakks/use-advanced-issue-search
Use advanced issue search
2 parents aecbf99 + 37896d6 commit 6b19a85

24 files changed

Lines changed: 1166 additions & 133 deletions

File tree

internal/featuredetection/detector_mock.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
2020
return gh.ProjectsV1Unsupported
2121
}
2222

23+
func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
24+
return advancedIssueSearchNotSupported, nil
25+
}
26+
2327
type EnabledDetectorMock struct{}
2428

2529
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@@ -37,3 +41,34 @@ func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error)
3741
func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
3842
return gh.ProjectsV1Supported
3943
}
44+
45+
func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
46+
return advancedIssueSearchNotSupported, nil
47+
}
48+
49+
type AdvancedIssueSearchDetectorMock struct {
50+
EnabledDetectorMock
51+
searchFeatures SearchFeatures
52+
}
53+
54+
func (md *AdvancedIssueSearchDetectorMock) SearchFeatures() (SearchFeatures, error) {
55+
return md.searchFeatures, nil
56+
}
57+
58+
func AdvancedIssueSearchUnsupported() *AdvancedIssueSearchDetectorMock {
59+
return &AdvancedIssueSearchDetectorMock{
60+
searchFeatures: advancedIssueSearchNotSupported,
61+
}
62+
}
63+
64+
func AdvancedIssueSearchSupportedAsOptIn() *AdvancedIssueSearchDetectorMock {
65+
return &AdvancedIssueSearchDetectorMock{
66+
searchFeatures: advancedIssueSearchSupportedAsOptIn,
67+
}
68+
}
69+
70+
func AdvancedIssueSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMock {
71+
return &AdvancedIssueSearchDetectorMock{
72+
searchFeatures: advancedIssueSearchSupportedAsOnlyBackend,
73+
}
74+
}

internal/featuredetection/feature_detection.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Detector interface {
1616
PullRequestFeatures() (PullRequestFeatures, error)
1717
RepositoryFeatures() (RepositoryFeatures, error)
1818
ProjectsV1() gh.ProjectsV1Support
19+
SearchFeatures() (SearchFeatures, error)
1920
}
2021

2122
type IssueFeatures struct {
@@ -55,6 +56,43 @@ var allRepositoryFeatures = RepositoryFeatures{
5556
AutoMerge: true,
5657
}
5758

59+
type SearchFeatures struct {
60+
// AdvancedIssueSearch indicates whether the host supports advanced issue
61+
// search via API calls.
62+
AdvancedIssueSearchAPI bool
63+
// AdvancedIssueSearchOptIn indicates whether the host supports advanced
64+
// issue search as an opt-in feature, which has to be explicitly enabled in
65+
// API calls.
66+
AdvancedIssueSearchAPIOptIn bool
67+
68+
// TODO advancedSearchFuture
69+
// When advanced issue search is supported in Pull Requests tab, or in
70+
// global search we can introduce more fields to reflect the support status.
71+
}
72+
73+
// advancedIssueSearchNotSupported mimics GHE <3.18 where advanced issue search
74+
// is either not supported or is not meant to be used due to not being stable
75+
// enough (i.e. in preview).
76+
var advancedIssueSearchNotSupported = SearchFeatures{
77+
AdvancedIssueSearchAPI: false,
78+
}
79+
80+
// advancedIssueSearchSupportedAsOptIn mimics github.com and GHE >=3.18 before
81+
// the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is still
82+
// present on the schema).
83+
var advancedIssueSearchSupportedAsOptIn = SearchFeatures{
84+
AdvancedIssueSearchAPI: true,
85+
AdvancedIssueSearchAPIOptIn: true,
86+
}
87+
88+
// advancedIssueSearchSupportedAsOnlyBackend mimics github.com and GHE >=3.18
89+
// after the full cleanup of temp types (i.e. ISSUE_ADVANCED search type is
90+
// removed from the schema).
91+
var advancedIssueSearchSupportedAsOnlyBackend = SearchFeatures{
92+
AdvancedIssueSearchAPI: true,
93+
AdvancedIssueSearchAPIOptIn: false,
94+
}
95+
5896
type detector struct {
5997
host string
6098
httpClient *http.Client
@@ -225,6 +263,101 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support {
225263
return gh.ProjectsV1Unsupported
226264
}
227265

266+
const (
267+
// enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that
268+
// supports advanced issue search and gh should use it.
269+
//
270+
// Note that advanced issue search is also available on GHES 3.17, but it's
271+
// at the preview stage and is not as mature as it is on github.com or later
272+
// GHES version.
273+
enterpriseAdvancedIssueSearchSupport = "3.18.0"
274+
)
275+
276+
func (d *detector) SearchFeatures() (SearchFeatures, error) {
277+
// TODO advancedIssueSearchCleanup
278+
// Once GHES 3.17 support ends, we don't need this and, probably, the entire search feature detection.
279+
280+
// Regarding the release of advanced issue search (AIS, for short), there
281+
// are three time spans/periods:
282+
//
283+
// 1. Pre-deprecation: where both legacy search and AIS are available
284+
// - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave differently
285+
// - REST: `advance_search=true` query parameter can be used to switch to AIS
286+
// 2. Deprecation: only AIS available
287+
// - GraphQL: `ISSUE` and `ISSUE_ADVANCED` search types in GraphQL behave the same (AIS)
288+
// - REST: `advance_search` query parameter has no effect (AIS)
289+
// 3. Cleanup: only AIS available
290+
// - GraphQL: `ISSUE` search type in GraphQL is the only available option (AIS)
291+
// - REST: `advance_search` query parameter has no effect (AIS)
292+
//
293+
// Since there's no schema-wise difference between pre-deprecation and
294+
// deprecation periods (i.e. `ISSUE_ADVANCED` is available during both),
295+
// we cannot figure out the exact time period. The consensus is to to use
296+
// the advanced search syntax during both periods.
297+
298+
var feature SearchFeatures
299+
300+
if ghauth.IsEnterprise(d.host) {
301+
enterpriseAISSupportVersion, err := version.NewVersion(enterpriseAdvancedIssueSearchSupport)
302+
if err != nil {
303+
return SearchFeatures{}, err
304+
}
305+
306+
hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host)
307+
if err != nil {
308+
return SearchFeatures{}, err
309+
}
310+
311+
if hostVersion.GreaterThanOrEqual(enterpriseAISSupportVersion) {
312+
// As of August 2025, advanced issue search is going to be available
313+
// on GHES 3.18+, including Issues tabs in repositories.
314+
feature.AdvancedIssueSearchAPI = true
315+
316+
// TODO advancedSearchFuture
317+
// When the advanced search syntax is supported in global search or
318+
// Pull Requests tabs (in repositories), we can add and enable the
319+
// corresponding fields.
320+
}
321+
} else {
322+
// As of August 2025, advanced issue search is available on github.com,
323+
// including Issues tabs in repositories.
324+
feature.AdvancedIssueSearchAPI = true
325+
326+
// TODO advancedSearchFuture
327+
// When the advanced search syntax is supported in global search or
328+
// Pull Requests tabs (in repositories), we can add and enable the
329+
// corresponding fields.
330+
}
331+
332+
if !feature.AdvancedIssueSearchAPI {
333+
return feature, nil
334+
}
335+
336+
var searchTypeFeatureDetection struct {
337+
SearchType struct {
338+
EnumValues []struct {
339+
Name string
340+
} `graphql:"enumValues(includeDeprecated: true)"`
341+
} `graphql:"SearchType: __type(name: \"SearchType\")"`
342+
}
343+
344+
gql := api.NewClientFromHTTP(d.httpClient)
345+
if err := gql.Query(d.host, "SearchType_enumValues", &searchTypeFeatureDetection, nil); err != nil {
346+
return SearchFeatures{}, err
347+
}
348+
349+
for _, enumValue := range searchTypeFeatureDetection.SearchType.EnumValues {
350+
if enumValue.Name == "ISSUE_ADVANCED" {
351+
// As long as ISSUE_ADVANCED is present on the schema, we should
352+
// explicitly opt-in when making API calls.
353+
feature.AdvancedIssueSearchAPIOptIn = true
354+
break
355+
}
356+
}
357+
358+
return feature, nil
359+
}
360+
228361
func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) {
229362
var metaResponse struct {
230363
InstalledVersion string `json:"installed_version"`

internal/featuredetection/feature_detection_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,149 @@ func TestProjectV1Support(t *testing.T) {
439439
})
440440
}
441441
}
442+
443+
func TestAdvancedIssueSearchSupport(t *testing.T) {
444+
withIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"ISSUE_ADVANCED"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`
445+
withoutIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`
446+
447+
tests := []struct {
448+
name string
449+
hostname string
450+
httpStubs func(*httpmock.Registry)
451+
wantFeatures SearchFeatures
452+
}{
453+
{
454+
name: "github.com, before ISSUE_ADVANCED cleanup",
455+
hostname: "github.com",
456+
httpStubs: func(reg *httpmock.Registry) {
457+
reg.Register(
458+
httpmock.GraphQL(`query SearchType_enumValues\b`),
459+
httpmock.StringResponse(withIssueAdvanced),
460+
)
461+
},
462+
wantFeatures: advancedIssueSearchSupportedAsOptIn,
463+
},
464+
{
465+
name: "github.com, after ISSUE_ADVANCED cleanup",
466+
hostname: "github.com",
467+
httpStubs: func(reg *httpmock.Registry) {
468+
reg.Register(
469+
httpmock.GraphQL(`query SearchType_enumValues\b`),
470+
httpmock.StringResponse(withoutIssueAdvanced),
471+
)
472+
},
473+
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
474+
},
475+
{
476+
name: "ghec data residency (ghe.com), before ISSUE_ADVANCED cleanup",
477+
hostname: "stampname.ghe.com",
478+
httpStubs: func(reg *httpmock.Registry) {
479+
reg.Register(
480+
httpmock.GraphQL(`query SearchType_enumValues\b`),
481+
httpmock.StringResponse(withIssueAdvanced),
482+
)
483+
},
484+
wantFeatures: advancedIssueSearchSupportedAsOptIn,
485+
},
486+
{
487+
name: "ghec data residency (ghe.com), after ISSUE_ADVANCED cleanup",
488+
hostname: "stampname.ghe.com",
489+
httpStubs: func(reg *httpmock.Registry) {
490+
reg.Register(
491+
httpmock.GraphQL(`query SearchType_enumValues\b`),
492+
httpmock.StringResponse(withoutIssueAdvanced),
493+
)
494+
},
495+
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
496+
},
497+
{
498+
name: "GHE 3.18, before ISSUE_ADVANCED cleanup",
499+
hostname: "git.my.org",
500+
httpStubs: func(reg *httpmock.Registry) {
501+
reg.Register(
502+
httpmock.REST("GET", "api/v3/meta"),
503+
httpmock.StringResponse(`{"installed_version":"3.18.0"}`),
504+
)
505+
reg.Register(
506+
httpmock.GraphQL(`query SearchType_enumValues\b`),
507+
httpmock.StringResponse(withIssueAdvanced),
508+
)
509+
},
510+
wantFeatures: advancedIssueSearchSupportedAsOptIn,
511+
},
512+
{
513+
name: "GHE 3.18, after ISSUE_ADVANCED cleanup",
514+
hostname: "git.my.org",
515+
httpStubs: func(reg *httpmock.Registry) {
516+
reg.Register(
517+
httpmock.REST("GET", "api/v3/meta"),
518+
httpmock.StringResponse(`{"installed_version":"3.18.0"}`),
519+
)
520+
reg.Register(
521+
httpmock.GraphQL(`query SearchType_enumValues\b`),
522+
httpmock.StringResponse(withoutIssueAdvanced),
523+
)
524+
},
525+
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
526+
},
527+
{
528+
name: "GHE >3.18, before ISSUE_ADVANCED cleanup",
529+
hostname: "git.my.org",
530+
httpStubs: func(reg *httpmock.Registry) {
531+
reg.Register(
532+
httpmock.REST("GET", "api/v3/meta"),
533+
httpmock.StringResponse(`{"installed_version":"3.18.1"}`),
534+
)
535+
reg.Register(
536+
httpmock.GraphQL(`query SearchType_enumValues\b`),
537+
httpmock.StringResponse(withIssueAdvanced),
538+
)
539+
},
540+
wantFeatures: advancedIssueSearchSupportedAsOptIn,
541+
},
542+
{
543+
name: "GHE >3.18, after ISSUE_ADVANCED cleanup",
544+
hostname: "git.my.org",
545+
httpStubs: func(reg *httpmock.Registry) {
546+
reg.Register(
547+
httpmock.REST("GET", "api/v3/meta"),
548+
httpmock.StringResponse(`{"installed_version":"3.18.1"}`),
549+
)
550+
reg.Register(
551+
httpmock.GraphQL(`query SearchType_enumValues\b`),
552+
httpmock.StringResponse(withoutIssueAdvanced),
553+
)
554+
},
555+
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
556+
},
557+
{
558+
name: "GHE <3.18 (no advanced issue search support)",
559+
hostname: "git.my.org",
560+
httpStubs: func(reg *httpmock.Registry) {
561+
reg.Register(
562+
httpmock.REST("GET", "api/v3/meta"),
563+
httpmock.StringResponse(`{"installed_version":"3.17.999"}`),
564+
)
565+
},
566+
wantFeatures: advancedIssueSearchNotSupported,
567+
},
568+
}
569+
570+
for _, tt := range tests {
571+
t.Run(tt.name, func(t *testing.T) {
572+
t.Parallel()
573+
reg := &httpmock.Registry{}
574+
if tt.httpStubs != nil {
575+
tt.httpStubs(reg)
576+
}
577+
httpClient := &http.Client{}
578+
httpmock.ReplaceTripper(httpClient, reg)
579+
580+
detector := NewDetector(httpClient, tt.hostname)
581+
582+
features, err := detector.SearchFeatures()
583+
require.NoError(t, err)
584+
require.Equal(t, tt.wantFeatures, features)
585+
})
586+
}
587+
}

pkg/cmd/extension/browse/browse_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/cli/cli/v2/internal/config"
14+
fd "github.com/cli/cli/v2/internal/featuredetection"
1415
"github.com/cli/cli/v2/internal/gh"
1516
"github.com/cli/cli/v2/internal/ghrepo"
1617
"github.com/cli/cli/v2/pkg/cmd/repo/view"
@@ -125,7 +126,7 @@ func Test_getExtensionRepos(t *testing.T) {
125126
}),
126127
)
127128

128-
searcher := search.NewSearcher(client, "github.com")
129+
searcher := search.NewSearcher(client, "github.com", &fd.DisabledDetectorMock{})
129130
emMock := &extensions.ExtensionManagerMock{}
130131
emMock.ListFunc = func() []extensions.Extension {
131132
return []extensions.Extension{

pkg/cmd/extension/command.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/MakeNowJust/heredoc"
1313
"github.com/cli/cli/v2/api"
1414
"github.com/cli/cli/v2/git"
15+
"github.com/cli/cli/v2/internal/featuredetection"
1516
"github.com/cli/cli/v2/internal/ghrepo"
1617
"github.com/cli/cli/v2/internal/tableprinter"
1718
"github.com/cli/cli/v2/internal/text"
@@ -164,7 +165,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
164165
query.Qualifiers = qualifiers
165166

166167
host, _ := cfg.Authentication().DefaultHost()
167-
searcher := search.NewSearcher(client, host)
168+
detector := featuredetection.NewDetector(client, host)
169+
searcher := search.NewSearcher(client, host, detector)
168170

169171
if webMode {
170172
url := searcher.URL(query)
@@ -507,7 +509,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
507509
return err
508510
}
509511

510-
searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host)
512+
detector := featuredetection.NewDetector(client, host)
513+
searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host, detector)
511514

512515
gc.Stderr = gio.Discard
513516

0 commit comments

Comments
 (0)