366 lines
9.8 KiB
Go
366 lines
9.8 KiB
Go
package search
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
const (
|
|
KindRepositories = "repositories"
|
|
KindCode = "code"
|
|
KindIssues = "issues"
|
|
KindCommits = "commits"
|
|
)
|
|
|
|
type Query struct {
|
|
// Keywords holds the list of keywords to search for. These keywords are
|
|
// treated as individual components of a search query, and will get quoted
|
|
// as needed. This is useful when the input can be supplied as a list of
|
|
// search keywords.
|
|
//
|
|
// This field is overridden by ImmutableKeywords.
|
|
Keywords []string
|
|
|
|
// ImmutableKeywords holds the search keywords as a single string, and will
|
|
// be treated as is (e.g. no additional quoting). This is useful when the
|
|
// input is meant to be taken verbatim from the user.
|
|
//
|
|
// This field takes precedence over Keywords.
|
|
ImmutableKeywords string
|
|
|
|
Kind string
|
|
Limit int
|
|
Order string
|
|
Page int
|
|
Qualifiers Qualifiers
|
|
Sort string
|
|
}
|
|
|
|
type Qualifiers struct {
|
|
Archived *bool
|
|
Assignee string
|
|
Author string
|
|
AuthorDate string
|
|
AuthorEmail string
|
|
AuthorName string
|
|
Base string
|
|
Closed string
|
|
Commenter string
|
|
Comments string
|
|
Committer string
|
|
CommitterDate string
|
|
CommitterEmail string
|
|
CommitterName string
|
|
Created string
|
|
Draft *bool
|
|
Extension string
|
|
Filename string
|
|
Followers string
|
|
Fork string
|
|
Forks string
|
|
GoodFirstIssues string
|
|
Hash string
|
|
Head string
|
|
HelpWantedIssues string
|
|
In []string
|
|
Interactions string
|
|
Involves string
|
|
Is []string
|
|
Label []string
|
|
Language string
|
|
License []string
|
|
Mentions string
|
|
Merge *bool
|
|
Merged string
|
|
Milestone string
|
|
No []string
|
|
Parent string
|
|
Path string
|
|
Project string
|
|
Pushed string
|
|
Reactions string
|
|
Repo []string
|
|
Review string
|
|
ReviewRequested string
|
|
ReviewedBy string
|
|
Size string
|
|
Stars string
|
|
State string
|
|
Status string
|
|
Team string
|
|
TeamReviewRequested string
|
|
Topic []string
|
|
Topics string
|
|
Tree string
|
|
Type string
|
|
Updated string
|
|
User []string
|
|
}
|
|
|
|
// String returns the string representation of the query which can be used with
|
|
// the legacy search backend, which is used in global search GUI (i.e.
|
|
// github.com/search), or Pull Requests tab (in repositories). Note that this is
|
|
// a common query format that can be used to search for various entity types
|
|
// (e.g., issues, commits, repositories, etc)
|
|
//
|
|
// With the legacy search backend, the query is made of concatenating keywords
|
|
// and qualifiers with whitespaces. Note that at the backend side, most of the
|
|
// repeated qualifiers are AND-ed, while a handful of qualifiers (i.e.
|
|
// is:private/public, repo:, user:, or in:) are implicitly OR-ed. The legacy
|
|
// search backend does not support the advanced syntax which allows for nested
|
|
// queries and explicit OR operators.
|
|
//
|
|
// At the moment, the advanced search syntax is only available for searching
|
|
// issues, and it's called advanced issue search.
|
|
func (q Query) StandardSearchString() string {
|
|
qualifiers := formatQualifiers(q.Qualifiers, nil)
|
|
var keywords []string
|
|
if q.ImmutableKeywords != "" {
|
|
keywords = []string{q.ImmutableKeywords}
|
|
} else if ks := formatKeywords(q.Keywords); len(ks) > 0 {
|
|
keywords = ks
|
|
}
|
|
all := append(keywords, qualifiers...)
|
|
return strings.TrimSpace(strings.Join(all, " "))
|
|
}
|
|
|
|
// AdvancedIssueSearchString returns the string representation of the query
|
|
// compatible with the advanced issue search syntax. The query can be used in
|
|
// Issues tab (of repositories) and the Issues dashboard (i.e.
|
|
// github.com/issues).
|
|
//
|
|
// As the name suggests, this query syntax is only supported for searching
|
|
// issues (i.e. issues and PRs). The advanced syntax allows nested queries and
|
|
// explicit OR operators. Unlike the legacy search backend, the advanced issue
|
|
// search does not OR repeated instances of special qualifiers (i.e.
|
|
// is:private/public, repo:, user:, or in:).
|
|
//
|
|
// To keep the gh experience consistent and backward-compatible, the mentioned
|
|
// special qualifiers are explicitly grouped and combined with an OR operator.
|
|
//
|
|
// The advanced syntax is documented at https://github.blog/changelog/2025-03-06-github-issues-projects-api-support-for-issues-advanced-search-and-more
|
|
func (q Query) AdvancedIssueSearchString() string {
|
|
qualifiers := strings.Join(formatQualifiers(q.Qualifiers, formatAdvancedIssueSearch), " ")
|
|
keywords := q.ImmutableKeywords
|
|
if keywords == "" {
|
|
keywords = strings.Join(formatKeywords(q.Keywords), " ")
|
|
}
|
|
|
|
if qualifiers == "" && keywords == "" {
|
|
return ""
|
|
}
|
|
|
|
if qualifiers != "" && keywords != "" {
|
|
// We should surround keywords with brackets to avoid leaking of any operators, especially "OR"s.
|
|
return fmt.Sprintf("( %s ) %s", keywords, qualifiers)
|
|
}
|
|
|
|
if keywords != "" {
|
|
return keywords
|
|
}
|
|
return qualifiers
|
|
}
|
|
|
|
func formatAdvancedIssueSearch(qualifier string, vs []string) (s []string, applicable bool) {
|
|
switch qualifier {
|
|
case "in":
|
|
return formatSpecialQualifiers("in", vs, [][]string{{"title", "body", "comments"}}), true
|
|
case "is":
|
|
return formatSpecialQualifiers("is", vs, [][]string{{"blocked", "blocking"}, {"closed", "open"}, {"issue", "pr"}, {"locked", "unlocked"}, {"merged", "unmerged"}, {"private", "public"}}), true
|
|
case "user", "repo":
|
|
return []string{groupWithOR(qualifier, vs)}, true
|
|
}
|
|
// Let the default formatting take over
|
|
return nil, false
|
|
}
|
|
|
|
func formatSpecialQualifiers(qualifier string, vs []string, specialGroupsToOR [][]string) []string {
|
|
specialGroups := make([][]string, len(specialGroupsToOR))
|
|
rest := make([]string, 0, len(vs))
|
|
for _, v := range vs {
|
|
var isSpecial bool
|
|
for i, subValuesToOR := range specialGroupsToOR {
|
|
if slices.Contains(subValuesToOR, v) {
|
|
specialGroups[i] = append(specialGroups[i], v)
|
|
isSpecial = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if isSpecial {
|
|
continue
|
|
}
|
|
|
|
rest = append(rest, v)
|
|
}
|
|
|
|
all := make([]string, 0, len(specialGroups)+len(rest))
|
|
|
|
for _, group := range specialGroups {
|
|
if len(group) == 0 {
|
|
continue
|
|
}
|
|
all = append(all, groupWithOR(qualifier, group))
|
|
}
|
|
|
|
if len(rest) > 0 {
|
|
for _, v := range rest {
|
|
all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v)))
|
|
}
|
|
}
|
|
|
|
slices.Sort(all)
|
|
return all
|
|
}
|
|
|
|
func groupWithOR(qualifier string, vs []string) string {
|
|
if len(vs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
all := make([]string, 0, len(vs))
|
|
for _, v := range vs {
|
|
all = append(all, fmt.Sprintf("%s:%s", qualifier, quote(v)))
|
|
}
|
|
|
|
if len(all) == 1 {
|
|
return all[0]
|
|
}
|
|
|
|
slices.Sort(all)
|
|
return fmt.Sprintf("(%s)", strings.Join(all, " OR "))
|
|
}
|
|
|
|
func (q Qualifiers) Map() map[string][]string {
|
|
m := map[string][]string{}
|
|
v := reflect.ValueOf(q)
|
|
t := reflect.TypeOf(q)
|
|
for i := 0; i < v.NumField(); i++ {
|
|
fieldName := t.Field(i).Name
|
|
key := camelToKebab(fieldName)
|
|
typ := v.FieldByName(fieldName).Kind()
|
|
value := v.FieldByName(fieldName)
|
|
switch typ {
|
|
case reflect.Ptr:
|
|
if value.IsNil() {
|
|
continue
|
|
}
|
|
v := reflect.Indirect(value)
|
|
m[key] = []string{fmt.Sprintf("%v", v)}
|
|
case reflect.Slice:
|
|
if value.IsNil() {
|
|
continue
|
|
}
|
|
s := []string{}
|
|
for i := 0; i < value.Len(); i++ {
|
|
if value.Index(i).IsZero() {
|
|
continue
|
|
}
|
|
s = append(s, fmt.Sprintf("%v", value.Index(i)))
|
|
}
|
|
m[key] = s
|
|
default:
|
|
if value.IsZero() {
|
|
continue
|
|
}
|
|
m[key] = []string{fmt.Sprintf("%v", value)}
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func quote(s string) string {
|
|
if strings.ContainsAny(s, " \"\t\r\n") {
|
|
return fmt.Sprintf("%q", s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// formatQualifiers renders qualifiers into a plain query.
|
|
//
|
|
// The formatter is a custom formatting function that can be used to modify the
|
|
// output of each qualifier. If the formatter returns (nil, false) the default
|
|
// formatting will be applied.
|
|
func formatQualifiers(qs Qualifiers, formatter func(qualifier string, vs []string) (s []string, applicable bool)) []string {
|
|
type entry struct {
|
|
key string
|
|
values []string
|
|
}
|
|
|
|
var all []entry
|
|
for k, vs := range qs.Map() {
|
|
if len(vs) == 0 {
|
|
continue
|
|
}
|
|
|
|
e := entry{key: k}
|
|
|
|
if formatter != nil {
|
|
if s, applicable := formatter(k, vs); applicable {
|
|
e.values = s
|
|
all = append(all, e)
|
|
continue
|
|
}
|
|
}
|
|
|
|
for _, v := range vs {
|
|
e.values = append(e.values, fmt.Sprintf("%s:%s", k, quote(v)))
|
|
}
|
|
if len(e.values) > 1 {
|
|
slices.Sort(e.values)
|
|
}
|
|
all = append(all, e)
|
|
}
|
|
|
|
slices.SortFunc(all, func(a, b entry) int {
|
|
return strings.Compare(a.key, b.key)
|
|
})
|
|
|
|
result := make([]string, 0, len(all))
|
|
for _, e := range all {
|
|
result = append(result, e.values...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func formatKeywords(ks []string) []string {
|
|
result := make([]string, len(ks))
|
|
for i, k := range ks {
|
|
before, after, found := strings.Cut(k, ":")
|
|
if !found {
|
|
result[i] = quote(k)
|
|
} else {
|
|
result[i] = fmt.Sprintf("%s:%s", before, quote(after))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// CamelToKebab returns a copy of the string s that is converted from camel case form to '-' separated form.
|
|
func camelToKebab(s string) string {
|
|
var output []rune
|
|
var segment []rune
|
|
for _, r := range s {
|
|
if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) {
|
|
output = addSegment(output, segment)
|
|
segment = nil
|
|
}
|
|
segment = append(segment, unicode.ToLower(r))
|
|
}
|
|
output = addSegment(output, segment)
|
|
return string(output)
|
|
}
|
|
|
|
func addSegment(inrune, segment []rune) []rune {
|
|
if len(segment) == 0 {
|
|
return inrune
|
|
}
|
|
if len(inrune) != 0 {
|
|
inrune = append(inrune, '-')
|
|
}
|
|
inrune = append(inrune, segment...)
|
|
return inrune
|
|
}
|