Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f16c267
fix(featuredetection): add feature detection for advanced issue search
babakks Aug 30, 2025
e9b3ac3
test(featuredetection): add tests for advanced issue search detection
babakks Aug 30, 2025
fd38b14
fix(search): add `AdvancedIssueSearchString` method
babakks Aug 31, 2025
392c286
test(search): add tests for `AdvancedIssueSearchString` method
babakks Aug 31, 2025
bf242ae
fix(search): sort qualifiers in advacned issue search syntax
babakks Aug 31, 2025
cb249e6
test(search): explain why `is:` and `in:` qualifiers used in test case
babakks Aug 31, 2025
257f143
fix(search): add feature detection dependency
babakks Aug 31, 2025
188098d
test(search): provide feature detection dependency
babakks Aug 31, 2025
3086b6f
refactor(search): sort qualifiers in query
babakks Aug 31, 2025
0104d8c
refactor: improve mock feature detector names
babakks Aug 31, 2025
1b2e2a2
fix(search): use advanced issue search when available
babakks Aug 31, 2025
89b39e2
test(search): test advanced search support
babakks Aug 31, 2025
f0a130d
refactor(search): improve special qualifier grouping
babakks Aug 31, 2025
8ab6e72
docs(search): improve docs for `Query.String` and `Query.AdvancedIss…
babakks Aug 31, 2025
99daa74
docs(search issues): mention advanced issue search takeover
babakks Aug 31, 2025
3573fdf
docs(search prs): mention advanced issue search takeover
babakks Aug 31, 2025
04cce6b
docs(search): improve `Searcher.URL` method docs
babakks Aug 31, 2025
6d14840
refactor(issue/pr list): support advanced issue search
babakks Aug 31, 2025
8a8b67e
test(pr shared): assert `ListURLWithQuery` works with advanced search…
babakks Aug 31, 2025
b4213ac
test(issue/pr list): assert integration with advanced issue search
babakks Aug 31, 2025
5747297
docs(issue list): explain use of advanced issue search syntax
babakks Sep 1, 2025
33f1f6e
docs(pr list): explain use of advanced issue search syntax
babakks Sep 1, 2025
87bd76c
docs: add cleanup/future TODO marks for advanced issue search changes
babakks Sep 1, 2025
20e8b9e
docs(issue list): fix incorrect formatting
babakks Sep 2, 2025
7b4ace9
docs(issue pr): fix incorrect formatting
babakks Sep 2, 2025
d56a902
docs(featuredetection): add godoc for min GHES version for advanced i…
babakks Sep 2, 2025
efddea7
test(search): improve test cases
babakks Sep 8, 2025
ef69901
refactor(search): rename `Query.String` to `StandardSearchString`
babakks Sep 8, 2025
02ee337
docs(issue list): mention advanced issue search syntax support
babakks Sep 8, 2025
02dc03c
docs(pr list): mention advanced issue search syntax support
babakks Sep 8, 2025
cc60d9c
docs(search issues): mention advanced issue search syntax support
babakks Sep 8, 2025
2c08f20
docs(search prs): mention advanced issue search syntax support
babakks Sep 8, 2025
43bedab
docs(featuredetection): remove unknown dates
babakks Sep 8, 2025
37896d6
fix(featuredetection): remove redundant `AdvancedIssueSearchWebInIssu…
babakks Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions internal/featuredetection/detector_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 AdvancedIssueSearchSupportedAsOptIn() *AdvancedIssueSearchDetectorMock {
return &AdvancedIssueSearchDetectorMock{
searchFeatures: advancedIssueSearchSupportedAsOptIn,
}
}

func AdvancedIssueSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMock {
return &AdvancedIssueSearchDetectorMock{
searchFeatures: advancedIssueSearchSupportedAsOnlyBackend,
}
}
133 changes: 133 additions & 0 deletions internal/featuredetection/feature_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Detector interface {
PullRequestFeatures() (PullRequestFeatures, error)
RepositoryFeatures() (RepositoryFeatures, error)
ProjectsV1() gh.ProjectsV1Support
SearchFeatures() (SearchFeatures, error)
}

type IssueFeatures struct {
Expand Down Expand Up @@ -55,6 +56,43 @@ 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

// 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
// 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,
}

// 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,
}

type detector struct {
host string
httpClient *http.Client
Expand Down Expand Up @@ -225,6 +263,101 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Unsupported
}

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"
Comment thread
babakks marked this conversation as resolved.
)

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:
//
// 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: 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: 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

// 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,
// including Issues tabs in repositories.
feature.AdvancedIssueSearchAPI = true

// 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.
}

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"`
Expand Down
146 changes: 146 additions & 0 deletions internal/featuredetection/feature_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
3 changes: 2 additions & 1 deletion pkg/cmd/extension/browse/browse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
7 changes: 5 additions & 2 deletions pkg/cmd/extension/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading