diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 552f197d9e1..c1facf37100 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -100,3 +100,15 @@ func AdvancedIssueSearchSupportedAsOnlyBackend() *AdvancedIssueSearchDetectorMoc searchFeatures: advancedIssueSearchSupportedAsOnlyBackend, } } + +func SemanticSearchSupported() *AdvancedIssueSearchDetectorMock { + return &AdvancedIssueSearchDetectorMock{ + searchFeatures: semanticSearchSupported, + } +} + +func SemanticSearchUnsupported() *AdvancedIssueSearchDetectorMock { + return &AdvancedIssueSearchDetectorMock{ + searchFeatures: semanticSearchUnsupported, + } +} diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 88997708cca..92fd95f7862 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -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. @@ -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 } @@ -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 } } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 6b6ed675180..2a2c56c7e66 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -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 @@ -457,10 +475,10 @@ 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", @@ -468,10 +486,10 @@ func TestAdvancedIssueSearchSupport(t *testing.T) { 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", @@ -479,10 +497,10 @@ 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: "ghec data residency (ghe.com), after ISSUE_ADVANCED cleanup", @@ -490,10 +508,10 @@ func TestAdvancedIssueSearchSupport(t *testing.T) { 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", diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 409cbf09b35..ba7ec0b8f36 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -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, @@ -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: + 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(` @@ -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 { @@ -78,6 +91,16 @@ 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) } @@ -85,6 +108,9 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c opts.Entity = shared.Both opts.Query.Qualifiers.Type = "" } + if searchType != "lexical" { + opts.Query.SearchType = searchType + } if c.Flags().Changed("order") { opts.Query.Order = order } @@ -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") diff --git a/pkg/cmd/search/issues/issues_test.go b/pkg/cmd/search/issues/issues_test.go index a1a1017f19c..b0bfe645270 100644 --- a/pkg/cmd/search/issues/issues_test.go +++ b/pkg/cmd/search/issues/issues_test.go @@ -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) { diff --git a/pkg/search/query.go b/pkg/search/query.go index e45a4438a23..939733da7f6 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -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 } @@ -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 + } diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 5b05e1619e5..48c2b7311ec 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -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 @@ -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 @@ -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()) } diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 9ed40322e13..5745b62a249 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1218,6 +1218,113 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) { } } +func TestSearcherIssuesSemanticSearch(t *testing.T) { + tests := []struct { + name string + searchType string + detector fd.Detector + wantValues url.Values + wantErr string + }{ + { + name: "semantic search sends search_type=semantic", + searchType: "semantic", + detector: fd.SemanticSearchSupported(), + wantValues: url.Values{"search_type": []string{"semantic"}}, + }, + { + name: "hybrid search sends search_type=hybrid", + searchType: "hybrid", + detector: fd.SemanticSearchSupported(), + wantValues: url.Values{"search_type": []string{"hybrid"}}, + }, + { + name: "lexical search sends no search_type param", + searchType: "", + detector: fd.SemanticSearchSupported(), + wantValues: url.Values{"search_type": nil}, // assert absence + }, + { + name: "semantic search not supported on host", + searchType: "semantic", + detector: fd.SemanticSearchUnsupported(), + wantErr: "semantic search is not supported on this host: github.com", + }, + { + name: "hybrid search not supported on host", + searchType: "hybrid", + detector: fd.SemanticSearchUnsupported(), + wantErr: "hybrid search is not supported on this host: github.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.wantErr == "" { + reg.Register( + httpmock.QueryMatcher("GET", "search/issues", tt.wantValues), + httpmock.JSONResponse(IssuesResult{}), + ) + } + + query := Query{ + Kind: KindIssues, + Limit: 30, + Keywords: []string{"keyword"}, + SearchType: tt.searchType, + } + + client := &http.Client{Transport: reg} + searcher := NewSearcher(client, "github.com", tt.detector) + + _, err := searcher.Issues(query) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSearcherIssuesSemanticSearchIsBoundedToSinglePage(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // The response advertises a next page via the Link header. Only the first + // page is registered, so if fetching were to paginate it would request an + // unregistered second page and fail. + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{"number": 1234}, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + reg.Register( + httpmock.QueryMatcher("GET", "search/issues", url.Values{"search_type": []string{"semantic"}}), + firstRes, + ) + + query := Query{ + Kind: KindIssues, + Limit: 100, + Keywords: []string{"keyword"}, + SearchType: "semantic", + } + + client := &http.Client{Transport: reg} + searcher := NewSearcher(client, "github.com", fd.SemanticSearchSupported()) + + result, err := searcher.Issues(query) + assert.NoError(t, err) + assert.Equal(t, 1, len(result.Items)) +} + func TestSearcherURL(t *testing.T) { query := Query{ Keywords: []string{"keyword"},