forgejo/modules/indexer/issues/internal/qstring.go
Shiny Nematoda 255ed593d3 feat(issue-search): support query syntax (#9109)
List of currently supported filters:

- `is:open` (or `-is:closed`)
- `is:closed` (or `-is:open`)
- `is:all`
- `author:<username>`
- `assignee:<username>`
- `review:<username>`
- `mentions:<username>`
- `modified:[>|<]<date>`, where `<date>` is the last update date.
- `sort:<by>:[asc|desc]`, where `<by>` is among
	- created
	- comments
	- updated
	- deadline

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9109
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Shiny Nematoda <snematoda.751k2@aleeas.com>
Co-committed-by: Shiny Nematoda <snematoda.751k2@aleeas.com>
2025-11-19 16:05:42 +01:00

271 lines
5.5 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package internal
import (
"context"
"io"
"strconv"
"strings"
"time"
"forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
)
type BoolOpt int
const (
BoolOptMust BoolOpt = iota
BoolOptShould
BoolOptNot
)
type Token struct {
Term string
Kind BoolOpt
Fuzzy bool
}
// Helper function to check if the term starts with a prefix.
func (tk *Token) IsOf(prefix string) bool {
return strings.HasPrefix(tk.Term, prefix) && len(tk.Term) > len(prefix)
}
func (tk *Token) ParseIssueReference() (int64, error) {
term := tk.Term
if len(term) > 1 && (term[0] == '#' || term[0] == '!') {
term = term[1:]
}
return strconv.ParseInt(term, 10, 64)
}
type Tokenizer struct {
in *strings.Reader
}
func (t *Tokenizer) next() (tk Token, err error) {
var (
sb strings.Builder
r rune
)
tk.Kind = BoolOptShould
tk.Fuzzy = true
// skip all leading white space
for {
if r, _, err = t.in.ReadRune(); err != nil || r != ' ' {
break
}
}
if err != nil {
return tk, err
}
// check for +/- op, increment to the next rune in both cases
switch r {
case '+':
tk.Kind = BoolOptMust
r, _, err = t.in.ReadRune()
case '-':
tk.Kind = BoolOptNot
r, _, err = t.in.ReadRune()
}
if err != nil {
return tk, err
}
// parse the string, escaping special characters
for esc := false; err == nil; r, _, err = t.in.ReadRune() {
if esc {
if !strings.ContainsRune("+-\\\"", r) {
sb.WriteRune('\\')
}
sb.WriteRune(r)
esc = false
continue
}
switch r {
case '\\':
esc = true
case '"':
if !tk.Fuzzy {
goto nextEnd
}
tk.Fuzzy = false
case ' ', '\t':
if tk.Fuzzy {
goto nextEnd
}
sb.WriteRune(r)
default:
sb.WriteRune(r)
}
}
nextEnd:
tk.Term = sb.String()
if err == io.EOF {
err = nil
} // do not consider EOF as an error at the end
return tk, err
}
type userFilter int
const (
userFilterAuthor userFilter = iota
userFilterAssign
userFilterMention
userFilterReview
)
// Parses the keyword and sets the
func (o *SearchOptions) WithKeyword(ctx context.Context, keyword string) (err error) {
if keyword == "" {
return nil
}
in := strings.NewReader(keyword)
it := Tokenizer{in: in}
var (
tokens []Token
userNames []string
userFilter []userFilter
)
for token, err := it.next(); err == nil; token, err = it.next() {
if token.Term == "" {
continue
}
// For an exact search (wrapped in quotes)
// push the token to the list.
if !token.Fuzzy {
tokens = append(tokens, token)
continue
}
// Otherwise, try to match the token with a preset filter.
switch {
// is:open => open & -is:open => closed
case token.Term == "is:open":
o.IsClosed = optional.Some(token.Kind == BoolOptNot)
// Similarly, is:closed & -is:closed
case token.Term == "is:closed":
o.IsClosed = optional.Some(token.Kind != BoolOptNot)
// The rest of the presets MUST NOT be a negation.
case token.Kind == BoolOptNot:
tokens = append(tokens, token)
// is:all: Do not consider -is:all.
case token.Term == "is:all":
o.IsClosed = optional.None[bool]()
// sort:<by>:[ asc | desc ],
case token.IsOf("sort:"):
o.SortBy = parseSortBy(token.Term[5:])
// modified:[ < | > ]<date>.
// for example, modified:>2025-08-29
case token.IsOf("modified:"):
switch token.Term[9] {
case '>':
o.UpdatedAfterUnix = toUnix(token.Term[10:])
case '<':
o.UpdatedBeforeUnix = toUnix(token.Term[10:])
default:
t := toUnix(token.Term[9:])
o.UpdatedAfterUnix = t
o.UpdatedBeforeUnix = t
}
// for user filter's
// append the names and roles
case token.IsOf("author:"):
userNames = append(userNames, token.Term[7:])
userFilter = append(userFilter, userFilterAuthor)
case token.IsOf("assignee:"):
userNames = append(userNames, token.Term[9:])
userFilter = append(userFilter, userFilterAssign)
case token.IsOf("review:"):
userNames = append(userNames, token.Term[7:])
userFilter = append(userFilter, userFilterReview)
case token.IsOf("mentions:"):
userNames = append(userNames, token.Term[9:])
userFilter = append(userFilter, userFilterMention)
default:
tokens = append(tokens, token)
}
}
if err != nil && err != io.EOF {
return err
}
o.Tokens = tokens
ids, err := user.GetUserIDsByNames(ctx, userNames, true)
if err != nil {
return err
}
for i, id := range ids {
// Skip all invalid IDs.
// Hopefully this won't be too astonishing for the user.
if id <= 0 {
continue
}
val := optional.Some(id)
switch userFilter[i] {
case userFilterAuthor:
o.PosterID = val
case userFilterAssign:
o.AssigneeID = val
case userFilterReview:
o.ReviewedID = val
case userFilterMention:
o.MentionID = val
}
}
return nil
}
func toUnix(value string) optional.Option[int64] {
time, err := time.Parse(time.DateOnly, value)
if err != nil {
log.Warn("Failed to parse date '%v'", err)
return optional.None[int64]()
}
return optional.Some(time.Unix())
}
func parseSortBy(sortBy string) SortBy {
switch sortBy {
case "created:asc":
return SortByCreatedAsc
case "created:desc":
return SortByCreatedDesc
case "comments:asc":
return SortByCommentsAsc
case "comments:desc":
return SortByCommentsDesc
case "updated:asc":
return SortByUpdatedAsc
case "updated:desc":
return SortByUpdatedDesc
case "deadline:asc":
return SortByDeadlineAsc
case "deadline:desc":
return SortByDeadlineDesc
default:
return SortByScore
}
}