The --type filter was concatenated into ImmutableKeywords in SearchQueryBuild, which bypassed search.Query's quoting and let values that contained quotes (or extra qualifiers) corrupt the final query. Per babakks's suggestion in the review thread, model it as a regular qualifier instead. Add an IssueType field to Qualifiers tagged `qualifier:"type"` so it shares a key with the existing Type field, and rework Qualifiers.Map to honour the tag and concatenate values when multiple fields share the same key. SearchQueryBuild now drops its bespoke type:X formatting and just sets Qualifiers.IssueType, leaving the keyword/qualifier escaping to pkg/search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
370 lines
10 KiB
Go
370 lines
10 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
|
|
IssueType string `qualifier:"type"`
|
|
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 "))
|
|
}
|
|
|
|
// Map turns the qualifiers into a slice-keyed map ready for query
|
|
// formatting. Multiple struct fields can share the same key when
|
|
// tagged with `qualifier:"<name>"`; in that case their values are
|
|
// concatenated under the shared key.
|
|
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++ {
|
|
field := t.Field(i)
|
|
key := field.Tag.Get("qualifier")
|
|
if key == "" {
|
|
key = camelToKebab(field.Name)
|
|
}
|
|
value := v.Field(i)
|
|
switch value.Kind() {
|
|
case reflect.Ptr:
|
|
if value.IsNil() {
|
|
continue
|
|
}
|
|
m[key] = append(m[key], fmt.Sprintf("%v", reflect.Indirect(value)))
|
|
case reflect.Slice:
|
|
if value.IsNil() {
|
|
continue
|
|
}
|
|
for j := 0; j < value.Len(); j++ {
|
|
if value.Index(j).IsZero() {
|
|
continue
|
|
}
|
|
m[key] = append(m[key], fmt.Sprintf("%v", value.Index(j)))
|
|
}
|
|
default:
|
|
if value.IsZero() {
|
|
continue
|
|
}
|
|
m[key] = append(m[key], 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
|
|
}
|