Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,15 @@ func AdvancedIssueSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMoc
searchFeatures: advancedIssueSearchSupportedAsOnlyBackend,
}
}

func SemanticSearchSupported() *AdvancedIssueSearchDetectorMock {
return &AdvancedIssueSearchDetectorMock{
searchFeatures: semanticSearchSupported,
}
}

func SemanticSearchUnsupported() *AdvancedIssueSearchDetectorMock {
return &AdvancedIssueSearchDetectorMock{
searchFeatures: semanticSearchUnsupported,
}
}
35 changes: 33 additions & 2 deletions internal/featuredetection/feature_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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 @@ -136,6 +143,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 @@ -446,11 +469,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
34 changes: 26 additions & 8 deletions internal/featuredetection/feature_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,24 @@ 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"}]}}}`
withoutIssueAdvancedWithSemantic := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"ISSUE_SEMANTIC"},{"name":"ISSUE_HYBRID"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`

dotcomSupportedAsOptIn := SearchFeatures{
AdvancedIssueSearchAPI: true,
AdvancedIssueSearchAPIOptIn: true,
SemanticSearch: true,
HybridSearch: true,
}
dotcomSupportedAsOnlyBackend := SearchFeatures{
AdvancedIssueSearchAPI: true,
SemanticSearch: true,
HybridSearch: true,
}

tests := []struct {
name string
hostname string
Expand All @@ -457,43 +475,43 @@ func TestAdvancedIssueSearchSupport(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
httpmock.StringResponse(withIssueAdvancedAndSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
wantFeatures: dotcomSupportedAsOptIn,
},
{
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),
httpmock.StringResponse(withoutIssueAdvancedWithSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
wantFeatures: dotcomSupportedAsOnlyBackend,
},
{
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),
httpmock.StringResponse(withIssueAdvancedAndSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
wantFeatures: dotcomSupportedAsOptIn,
},
{
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),
httpmock.StringResponse(withoutIssueAdvancedWithSemantic),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
wantFeatures: dotcomSupportedAsOnlyBackend,
},
{
name: "GHE 3.18, before ISSUE_ADVANCED cleanup",
Expand Down
28 changes: 28 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 (up to 100), 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 "auth fails on mobile" --search-type semantic

# Search issues using hybrid (keyword + semantic) ranking
$ gh search issues "login 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,26 @@ 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 semantic search")
}
if semanticSearch && c.Flags().Changed("include-prs") {
return cmdutil.FlagErrorf("semantic search is scoped to issues and cannot be combined with `--include-prs`")
}
if semanticSearch && (c.Flags().Changed("sort") || c.Flags().Changed("order")) {
return cmdutil.FlagErrorf("`--sort` and `--order` are not supported with semantic search")
}
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" {
opts.Query.SearchType = searchType
}
if c.Flags().Changed("order") {
opts.Query.Order = order
}
Expand Down Expand Up @@ -145,6 +171,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,
SearchType: "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,
SearchType: "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
7 changes: 6 additions & 1 deletion pkg/search/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type Query struct {
Order string
Page int
Qualifiers Qualifiers
// SearchType 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.
SearchType string
Sort string
}

Expand Down Expand Up @@ -366,5 +370,6 @@ func addSegment(inrune, segment []rune) []rune {
inrune = append(inrune, '-')
}
inrune = append(inrune, segment...)
return inrune
return inruneinternal/featuredetection/detector_mock.go

}
21 changes: 21 additions & 0 deletions pkg/search/searcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
func (s searcher) Issues(query Query) (IssuesResult, error) {
result := IssuesResult{}

// Semantic and hybrid searches use a separate, smaller rate-limit bucket and
// are relevance-ranked, so bound fetching to a single page.
singlePage := query.SearchType == "semantic" || query.SearchType == "hybrid"

numItemsToRetrieve := query.Limit
query.Limit = min(numItemsToRetrieve, maxPerPage)
query.Page = 1
Expand All @@ -177,6 +181,10 @@ func (s searcher) Issues(query Query) (IssuesResult, error) {
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd

if singlePage {
break
}

query.Page = nextPage(link)
if query.Page == 0 {
break
Expand Down Expand Up @@ -223,6 +231,19 @@ func (s searcher) search(query Query, result interface{}) (string, error) {
qs.Set("advanced_search", "true")
}
}

switch query.SearchType {
case "semantic":
if !features.SemanticSearch {
return "", fmt.Errorf("semantic search is not supported on this host: %s", s.host)
}
qs.Set("search_type", query.SearchType)
case "hybrid":
if !features.HybridSearch {
return "", fmt.Errorf("hybrid search is not supported on this host: %s", s.host)
}
qs.Set("search_type", query.SearchType)
}
} else {
qs.Set("q", query.StandardSearchString())
}
Expand Down
Loading