Add --search-type flag for semantic and hybrid issue search by michaeljacholke · Pull Request #14006 · cli/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions internal/featuredetection/detector_mock.go
35 changes: 33 additions & 2 deletions internal/featuredetection/feature_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ type SearchFeatures struct {
// API calls.
AdvancedIssueSearchAPIOptIn bool

// SemanticSearch indicates whether the host supports semantic issue search
// (search_type=semantic). Dotcom-only; absent on single-tenant GHES.
SemanticSearch bool
// HybridSearch indicates whether the host supports hybrid issue search
// (search_type=hybrid). Dotcom-only; absent on single-tenant GHES.
HybridSearch 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.
Expand Down Expand Up @@ -137,6 +144,22 @@ var advancedIssueSearchSupportedAsOnlyBackend = SearchFeatures{
AdvancedIssueSearchAPIOptIn: false,
}

// semanticSearchSupported mimics a Dotcom host (github.com or ghe.com data
// residency) where semantic and hybrid issue search are available.
var semanticSearchSupported = SearchFeatures{
AdvancedIssueSearchAPI: true,
SemanticSearch: true,
HybridSearch: true,
}

// semanticSearchUnsupported mimics a single-tenant GHES host where advanced
// issue search is available but semantic and hybrid search are not.
var semanticSearchUnsupported = SearchFeatures{
AdvancedIssueSearchAPI: true,
SemanticSearch: false,
HybridSearch: false,
}

type ReleaseFeatures struct {
ImmutableReleases bool
}
Expand Down Expand Up @@ -447,11 +470,19 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) {
}

for _, enumValue := range searchTypeFeatureDetection.SearchType.EnumValues {
if enumValue.Name == "ISSUE_ADVANCED" {
switch enumValue.Name {
case "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
case "ISSUE_SEMANTIC":
// ISSUE_SEMANTIC is gated to Dotcom (github.com and ghe.com data
// residency) and absent on single-tenant GHES.
feature.SemanticSearch = true
case "ISSUE_HYBRID":
// ISSUE_HYBRID is gated to Dotcom (github.com and ghe.com data
// residency) and absent on single-tenant GHES.
feature.HybridSearch = true
}
}

Expand Down
46 changes: 18 additions & 28 deletions internal/featuredetection/feature_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,55 +445,45 @@ 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"}]}}}`

// Dotcom hosts (github.com and ghe.com data residency) additionally expose
// ISSUE_SEMANTIC and ISSUE_HYBRID on the SearchType enum. Single-tenant GHES
// does not.
withIssueAdvancedAndSemantic := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"ISSUE_ADVANCED"},{"name":"ISSUE_SEMANTIC"},{"name":"ISSUE_HYBRID"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`

dotcomSupported := SearchFeatures{
AdvancedIssueSearchAPI: true,
AdvancedIssueSearchAPIOptIn: true,
SemanticSearch: true,
HybridSearch: true,
}

tests := []struct {
name string
hostname string
httpStubs func(*httpmock.Registry)
wantFeatures SearchFeatures
}{
{
name: "github.com, before ISSUE_ADVANCED cleanup",
name: "github.com",
hostname: "github.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
httpmock.StringResponse(withIssueAdvancedAndSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
wantFeatures: dotcomSupported,
},
{
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",
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withoutIssueAdvanced),
httpmock.StringResponse(withIssueAdvancedAndSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
wantFeatures: dotcomSupported,
},
{
name: "GHE 3.18, before ISSUE_ADVANCED cleanup",
Expand Down
29 changes: 29 additions & 0 deletions pkg/cmd/search/issues/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
var noAssignee, noLabel, noMilestone, noProject bool
var order, sort string
var appAuthor string
var searchType string
opts := &shared.IssuesOptions{
Browser: f.Browser,
Entity: shared.Issues,
Expand All @@ -41,6 +42,12 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
%[1]s--search%[1]s query. For more information about advanced issue search, see:
<https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/filtering-and-searching-issues-and-pull-requests#building-advanced-filters-for-issues>

Use %[1]s--search-type%[1]s to select semantic or hybrid (keyword + semantic)
ranking instead of the default lexical search. Semantic and hybrid search are
scoped to issues, are relevance-ranked (so %[1]s--sort%[1]s and %[1]s--order%[1]s
cannot be used), return a single page of results, and are not available on
GitHub Enterprise Server.

For more information on handling search queries containing a hyphen, run %[1]sgh search --help%[1]s.
`, "`"),
Example: heredoc.Doc(`
Expand All @@ -67,6 +74,12 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c

# Search issues only from un-archived repositories (default is all repositories)
$ gh search issues --owner github --archived=false

# Search issues using semantic (natural-language) ranking
$ gh search issues "feature broken on web" --search-type semantic

# Search issues using hybrid (keyword + semantic) ranking
$ gh search issues "feature broken" --search-type hybrid
`),
RunE: func(c *cobra.Command, args []string) error {
if len(args) == 0 && c.Flags().NFlag() == 0 {
Expand All @@ -78,13 +91,27 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
if c.Flags().Changed("author") && c.Flags().Changed("app") {
return cmdutil.FlagErrorf("specify only `--author` or `--app`")
}
semanticSearch := searchType == "semantic" || searchType == "hybrid"
if semanticSearch && opts.WebMode {
return cmdutil.FlagErrorf("`--web` is not supported with %s search", searchType)
}
if semanticSearch && includePrs {
return cmdutil.FlagErrorf("%s search is scoped to issues and cannot be combined with `--include-prs`", searchType)
}
if semanticSearch && (c.Flags().Changed("sort") || c.Flags().Changed("order")) {
return cmdutil.FlagErrorf("`--sort` and `--order` are not supported with %s search", searchType)
}
Comment thread
babakks marked this conversation as resolved.
if c.Flags().Changed("app") {
opts.Query.Qualifiers.Author = fmt.Sprintf("app/%s", appAuthor)
}
if includePrs {
opts.Entity = shared.Both
opts.Query.Qualifiers.Type = ""
}
if searchType != "lexical" {
// We don't submit `lexical` as search type as it's the default API behaviour.
opts.Query.IssueSearchType = searchType
}
Comment thread
babakks marked this conversation as resolved.
if c.Flags().Changed("order") {
opts.Query.Order = order
}
Expand Down Expand Up @@ -145,6 +172,8 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
"updated",
}, "Sort fetched results")

cmdutil.StringEnumFlag(cmd, &searchType, "search-type", "", "lexical", []string{"lexical", "semantic", "hybrid"}, "Type of issue search to perform")

// Query qualifier flags
cmd.Flags().BoolVar(&includePrs, "include-prs", false, "Include pull requests in results")
cmd.Flags().StringVar(&appAuthor, "app", "", "Filter by GitHub App author")
Expand Down
68 changes: 68 additions & 0 deletions pkg/cmd/search/issues/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,74 @@ func TestNewCmdIssues(t *testing.T) {
},
},
},
{
name: "search-type semantic flag",
input: "test --search-type semantic",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{"test"},
Kind: "issues",
Limit: 30,
IssueSearchType: "semantic",
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "search-type hybrid flag",
input: "test --search-type hybrid",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{"test"},
Kind: "issues",
Limit: 30,
IssueSearchType: "hybrid",
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "search-type lexical flag sends no search type",
input: "test --search-type lexical",
output: shared.IssuesOptions{
Query: search.Query{
Keywords: []string{"test"},
Kind: "issues",
Limit: 30,
Qualifiers: search.Qualifiers{Type: "issue"},
},
},
},
{
name: "invalid search-type flag",
input: "test --search-type bogus",
wantErr: true,
errMsg: "invalid argument \"bogus\" for \"--search-type\" flag: valid values are {lexical|semantic|hybrid}",
},
{
name: "search-type semantic with include-prs flag",
input: "test --search-type semantic --include-prs",
wantErr: true,
errMsg: "semantic search is scoped to issues and cannot be combined with `--include-prs`",
},
{
name: "search-type semantic with web flag",
input: "test --search-type semantic --web",
wantErr: true,
errMsg: "`--web` is not supported with semantic search",
},
{
name: "search-type semantic with sort flag",
input: "test --search-type semantic --sort comments",
wantErr: true,
errMsg: "`--sort` and `--order` are not supported with semantic search",
},
{
name: "search-type semantic with order flag",
input: "test --search-type semantic --order asc",
wantErr: true,
errMsg: "`--sort` and `--order` are not supported with semantic search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
9 changes: 8 additions & 1 deletion pkg/search/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ type Query struct {
Order string
Page int
Qualifiers Qualifiers
Sort string
// IssueSearchType selects the issue search backend ("semantic" or "hybrid");
// empty uses the default lexical search. Maps to the REST search_type
// parameter, not the q string.
//
// Note that search type (semantic/hybrid) is independent of advanced issue
// search (which is more about query parsing).
IssueSearchType string
Sort string
}

type Qualifiers struct {
Expand Down
21 changes: 21 additions & 0 deletions pkg/search/searcher.go
Loading