Add greppable TODO identifiers above all if-statements that reference featuredetection struct fields, as required by the featuredetection linter. This ensures every feature detection branch is tagged for future cleanup when GHES gains support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
347 lines
9.3 KiB
Go
347 lines
9.3 KiB
Go
package search
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
fd "github.com/cli/cli/v2/internal/featuredetection"
|
|
"github.com/cli/cli/v2/internal/ghinstance"
|
|
)
|
|
|
|
const (
|
|
// GitHub API has a limit of 100 per page
|
|
maxPerPage = 100
|
|
orderKey = "order"
|
|
sortKey = "sort"
|
|
)
|
|
|
|
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
|
|
var pageRE = regexp.MustCompile(`(\?|&)page=(\d*)`)
|
|
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
|
|
|
|
//go:generate moq -rm -out searcher_mock.go . Searcher
|
|
type Searcher interface {
|
|
Code(Query) (CodeResult, error)
|
|
Commits(Query) (CommitsResult, error)
|
|
Repositories(Query) (RepositoriesResult, error)
|
|
Issues(Query) (IssuesResult, error)
|
|
URL(Query) string
|
|
}
|
|
|
|
type searcher struct {
|
|
client *http.Client
|
|
detector fd.Detector
|
|
host string
|
|
}
|
|
|
|
type httpError struct {
|
|
Errors []httpErrorItem
|
|
Message string
|
|
RequestURL *url.URL
|
|
StatusCode int
|
|
}
|
|
|
|
type httpErrorItem struct {
|
|
Code string
|
|
Field string
|
|
Message string
|
|
Resource string
|
|
}
|
|
|
|
func NewSearcher(client *http.Client, host string, detector fd.Detector) Searcher {
|
|
return &searcher{
|
|
client: client,
|
|
host: host,
|
|
detector: detector,
|
|
}
|
|
}
|
|
|
|
func (s searcher) Code(query Query) (CodeResult, error) {
|
|
result := CodeResult{}
|
|
|
|
// We will request either the query limit if it's less than 1 page, or our max page size.
|
|
// This number doesn't change to keep a valid offset.
|
|
//
|
|
// For example, say we want 150 items out of 500.
|
|
// We request page #1 for 100 items and get items 0 to 99.
|
|
// Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149.
|
|
// If we were to request page #2 for 50 items, we would instead get items 50 to 99.
|
|
numItemsToRetrieve := query.Limit
|
|
query.Limit = min(numItemsToRetrieve, maxPerPage)
|
|
query.Page = 1
|
|
|
|
for numItemsToRetrieve > 0 {
|
|
page := CodeResult{}
|
|
link, err := s.search(query, &page)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
// If we're going to reach the requested limit, only add that many items,
|
|
// otherwise add all the results.
|
|
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
|
|
result.IncompleteResults = page.IncompleteResults
|
|
// The API returns how many items match the query in every response.
|
|
// With the example above, this would be 500.
|
|
result.Total = page.Total
|
|
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
|
|
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
|
|
|
|
query.Page = nextPage(link)
|
|
if query.Page == 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s searcher) Commits(query Query) (CommitsResult, error) {
|
|
result := CommitsResult{}
|
|
|
|
numItemsToRetrieve := query.Limit
|
|
query.Limit = min(numItemsToRetrieve, maxPerPage)
|
|
query.Page = 1
|
|
|
|
for numItemsToRetrieve > 0 {
|
|
page := CommitsResult{}
|
|
link, err := s.search(query, &page)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
|
|
result.IncompleteResults = page.IncompleteResults
|
|
result.Total = page.Total
|
|
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
|
|
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
|
|
|
|
query.Page = nextPage(link)
|
|
if query.Page == 0 {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
|
|
result := RepositoriesResult{}
|
|
|
|
numItemsToRetrieve := query.Limit
|
|
query.Limit = min(numItemsToRetrieve, maxPerPage)
|
|
query.Page = 1
|
|
|
|
for numItemsToRetrieve > 0 {
|
|
page := RepositoriesResult{}
|
|
link, err := s.search(query, &page)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
|
|
result.IncompleteResults = page.IncompleteResults
|
|
result.Total = page.Total
|
|
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
|
|
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
|
|
|
|
query.Page = nextPage(link)
|
|
if query.Page == 0 {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s searcher) Issues(query Query) (IssuesResult, error) {
|
|
result := IssuesResult{}
|
|
|
|
numItemsToRetrieve := query.Limit
|
|
query.Limit = min(numItemsToRetrieve, maxPerPage)
|
|
query.Page = 1
|
|
for numItemsToRetrieve > 0 {
|
|
page := IssuesResult{}
|
|
link, err := s.search(query, &page)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
|
|
result.IncompleteResults = page.IncompleteResults
|
|
result.Total = page.Total
|
|
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
|
|
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
|
|
|
|
query.Page = nextPage(link)
|
|
if query.Page == 0 {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// search makes a single-page REST search request for code, commits, issues, prs, or repos,
|
|
// and returns the link header from response for further pagination calls. If the link header
|
|
// is not set on the response, empty string is returned.
|
|
//
|
|
// The result argument is populated with the following information:
|
|
//
|
|
// - Total: the number of search results matching the query, which may exceed the number of items returned
|
|
// - IncompleteResults: whether the search request exceeded search time limit, potentially being incomplete
|
|
// - Items: the actual matching search results, up to 100 max items per page
|
|
//
|
|
// For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28.
|
|
func (s searcher) search(query Query, result interface{}) (string, error) {
|
|
path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind)
|
|
qs := url.Values{}
|
|
qs.Set("page", strconv.Itoa(query.Page))
|
|
qs.Set("per_page", strconv.Itoa(query.Limit))
|
|
|
|
if query.Kind == KindIssues {
|
|
// TODO advancedIssueSearchCleanup
|
|
// We won't need feature detection when GHES 3.17 support ends, since
|
|
// the advanced issue search is the only available search backend for
|
|
// issues.
|
|
features, err := s.detector.SearchFeatures()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !features.AdvancedIssueSearchAPI {
|
|
qs.Set("q", query.StandardSearchString())
|
|
} else {
|
|
qs.Set("q", query.AdvancedIssueSearchString())
|
|
|
|
// TODO advancedIssueSearchCleanup
|
|
if features.AdvancedIssueSearchAPIOptIn {
|
|
// Advanced syntax should be explicitly enabled
|
|
qs.Set("advanced_search", "true")
|
|
}
|
|
}
|
|
} else {
|
|
qs.Set("q", query.StandardSearchString())
|
|
}
|
|
|
|
if query.Order != "" {
|
|
qs.Set(orderKey, query.Order)
|
|
}
|
|
if query.Sort != "" {
|
|
qs.Set(sortKey, query.Sort)
|
|
}
|
|
url := fmt.Sprintf("%s?%s", path, qs.Encode())
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
if query.Kind == KindCode {
|
|
req.Header.Set("Accept", "application/vnd.github.text-match+json")
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
link := resp.Header.Get("Link")
|
|
|
|
success := resp.StatusCode >= 200 && resp.StatusCode < 300
|
|
if !success {
|
|
return link, handleHTTPError(resp)
|
|
}
|
|
decoder := json.NewDecoder(resp.Body)
|
|
err = decoder.Decode(result)
|
|
if err != nil {
|
|
return link, err
|
|
}
|
|
return link, nil
|
|
}
|
|
|
|
// URL returns URL to the global search in web GUI (i.e. github.com/search).
|
|
func (s searcher) URL(query Query) string {
|
|
path := fmt.Sprintf("https://%s/search", s.host)
|
|
qs := url.Values{}
|
|
qs.Set("type", query.Kind)
|
|
|
|
// TODO advancedSearchFuture
|
|
// Currently, the global search GUI does not support the advanced issue
|
|
// search syntax (even for the issues/PRs tab on the sidebar). When the GUI
|
|
// is updated, we can use feature detection, and, if available, use the
|
|
// advanced search syntax.
|
|
qs.Set("q", query.StandardSearchString())
|
|
|
|
if query.Order != "" {
|
|
qs.Set(orderKey, query.Order)
|
|
}
|
|
if query.Sort != "" {
|
|
qs.Set(sortKey, query.Sort)
|
|
}
|
|
url := fmt.Sprintf("%s?%s", path, qs.Encode())
|
|
return url
|
|
}
|
|
|
|
func (err httpError) Error() string {
|
|
if err.StatusCode != 422 || len(err.Errors) == 0 {
|
|
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
|
|
}
|
|
query := strings.TrimSpace(err.RequestURL.Query().Get("q"))
|
|
return fmt.Sprintf("Invalid search query %q.\n%s", query, err.Errors[0].Message)
|
|
}
|
|
|
|
func handleHTTPError(resp *http.Response) error {
|
|
httpError := httpError{
|
|
RequestURL: resp.Request.URL,
|
|
StatusCode: resp.StatusCode,
|
|
}
|
|
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
|
|
httpError.Message = resp.Status
|
|
return httpError
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(body, &httpError); err != nil {
|
|
return err
|
|
}
|
|
return httpError
|
|
}
|
|
|
|
// nextPage extracts the next page number from an API response's link header. if
|
|
// the provided link header is empty or there is no next page, zero is returned.
|
|
//
|
|
// See API [docs] on pagination for more information.
|
|
//
|
|
// [docs]: https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
|
|
func nextPage(link string) (page int) {
|
|
// When using pagination, responses get a "Link" field in their header.
|
|
// When a next page is available, "Link" contains a link to the next page
|
|
// tagged with rel="next".
|
|
for _, m := range linkRE.FindAllStringSubmatch(link, -1) {
|
|
if !(len(m) > 2 && m[2] == "next") {
|
|
continue
|
|
}
|
|
p := pageRE.FindStringSubmatch(m[1])
|
|
if len(p) == 3 {
|
|
i, err := strconv.Atoi(p[2])
|
|
if err == nil {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|