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>
This commit is contained in:
Shiny Nematoda 2025-11-19 16:05:42 +01:00 committed by Gusted
parent 4d0c7db6cd
commit 255ed593d3
26 changed files with 870 additions and 296 deletions

View file

@ -73,6 +73,12 @@ func (handler Handler) handleTemplateNode(fset *token.FileSet, node tmplParser.N
funcname = nodeVar.Ident[2]
}
if funcname == "IterWithTr" {
for i := 2; i < len(nodeCommand.Args); i += 2 {
handler.handleTemplateMsgid(fset, nodeCommand.Args[i])
}
}
var gotUnexpectedInvoke *int
ltf, ok := handler.LocaleTrFunctions[funcname]
if !ok {

View file

@ -9,6 +9,7 @@ import (
indexer_internal "forgejo.org/modules/indexer/internal"
inner_bleve "forgejo.org/modules/indexer/internal/bleve"
"forgejo.org/modules/indexer/issues/internal"
"forgejo.org/modules/optional"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
@ -154,16 +155,9 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
// Search searches for issues by given conditions.
// Returns the matching issue IDs
func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
var queries []query.Query
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
if len(tokens) > 0 {
q := bleve.NewBooleanQuery()
for _, token := range tokens {
for _, token := range options.Tokens {
innerQ := bleve.NewDisjunctionQuery(
inner_bleve.MatchPhraseQuery(token.Term, "title", issueIndexerAnalyzer, token.Fuzzy, 2.0),
inner_bleve.MatchPhraseQuery(token.Term, "content", issueIndexerAnalyzer, token.Fuzzy, 1.0),
@ -175,6 +169,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
innerQ.AddQuery(idQuery)
}
if len(options.Tokens) == 1 {
q.AddMust(innerQ)
break
}
switch token.Kind {
case internal.BoolOptMust:
q.AddMust(innerQ)
@ -184,9 +183,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
q.AddMustNot(innerQ)
}
}
queries = append(queries, q)
}
var filters []query.Query
if len(options.RepoIDs) > 0 || options.AllPublic {
var repoQueries []query.Query
for _, repoID := range options.RepoIDs {
@ -195,7 +193,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.AllPublic {
repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public"))
}
queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(repoQueries...))
}
if options.PriorityRepoID.Has() {
@ -203,41 +201,36 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
eq.SetBoost(10.0)
meh := bleve.NewMatchAllQuery()
meh.SetBoost(0)
should := bleve.NewDisjunctionQuery(eq, meh)
queries = append(queries, should)
q.AddShould(bleve.NewDisjunctionQuery(eq, meh))
}
if options.IsPull.Has() {
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
filters = append(filters, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
}
if options.IsClosed.Has() {
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed"))
filters = append(filters, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed"))
}
if options.NoLabelOnly {
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label"))
filters = append(filters, inner_bleve.BoolFieldQuery(true, "no_label"))
} else {
if len(options.IncludedLabelIDs) > 0 {
var includeQueries []query.Query
for _, labelID := range options.IncludedLabelIDs {
includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
}
queries = append(queries, bleve.NewConjunctionQuery(includeQueries...))
filters = append(filters, includeQueries...)
} else if len(options.IncludedAnyLabelIDs) > 0 {
var includeQueries []query.Query
for _, labelID := range options.IncludedAnyLabelIDs {
includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
}
queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(includeQueries...))
}
if len(options.ExcludedLabelIDs) > 0 {
var excludeQueries []query.Query
for _, labelID := range options.ExcludedLabelIDs {
q := bleve.NewBooleanQuery()
q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
excludeQueries = append(excludeQueries, q)
}
queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...))
}
}
@ -246,48 +239,41 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
for _, milestoneID := range options.MilestoneIDs {
milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id"))
}
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(milestoneQueries...))
}
if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
for key, val := range map[string]optional.Option[int64]{
"project_id": options.ProjectID,
"project_board_id": options.ProjectColumnID,
"poster_id": options.PosterID,
"assignee_id": options.AssigneeID,
"mention_ids": options.MentionID,
"reviewed_ids": options.ReviewedID,
"review_requested_ids": options.ReviewRequestedID,
"subscriber_ids": options.SubscriberID,
} {
if val.Has() {
filters = append(filters, inner_bleve.NumericEqualityQuery(val.Value(), key))
}
if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
}
if options.PosterID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id"))
}
if options.AssigneeID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id"))
}
if options.MentionID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.MentionID.Value(), "mention_ids"))
}
if options.ReviewedID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewedID.Value(), "reviewed_ids"))
}
if options.ReviewRequestedID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewRequestedID.Value(), "review_requested_ids"))
}
if options.SubscriberID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.SubscriberID.Value(), "subscriber_ids"))
}
if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(
filters = append(filters, inner_bleve.NumericRangeInclusiveQuery(
options.UpdatedAfterUnix,
options.UpdatedBeforeUnix,
"updated_unix"))
}
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
if len(queries) == 0 {
switch len(filters) {
case 0:
break
case 1:
q.Filter = filters[0]
default:
q.Filter = bleve.NewConjunctionQuery(filters...)
}
var indexerQuery query.Query = q
if q.Must == nil && q.MustNot == nil && q.Should == nil && len(filters) == 0 {
indexerQuery = bleve.NewMatchAllQuery()
}

View file

@ -5,7 +5,6 @@ package db
import (
"context"
"strconv"
"forgejo.org/models/db"
issue_model "forgejo.org/models/issues"
@ -54,36 +53,34 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
cond := builder.NewCond()
var priorityIssueIndex int64
if options.Keyword != "" {
if len(options.Tokens) != 0 {
repoCond := builder.In("repo_id", options.RepoIDs)
if len(options.RepoIDs) == 1 {
repoCond = builder.Eq{"repo_id": options.RepoIDs[0]}
}
subQuery := builder.Select("id").From("issue").Where(repoCond)
for _, token := range options.Tokens {
cond = builder.Or(
db.BuildCaseInsensitiveLike("issue.name", options.Keyword),
db.BuildCaseInsensitiveLike("issue.content", options.Keyword),
db.BuildCaseInsensitiveLike("issue.name", token.Term),
db.BuildCaseInsensitiveLike("issue.content", token.Term),
builder.In("issue.id", builder.Select("issue_id").
From("comment").
Where(builder.And(
builder.Eq{"type": issue_model.CommentTypeComment},
builder.In("issue_id", subQuery),
db.BuildCaseInsensitiveLike("content", options.Keyword),
db.BuildCaseInsensitiveLike("content", token.Term),
)),
),
)
term := options.Keyword
if term[0] == '#' || term[0] == '!' {
term = term[1:]
}
if issueID, err := strconv.ParseInt(term, 10, 64); err == nil {
if ref, err := token.ParseIssueReference(); err != nil {
cond = builder.Or(
builder.Eq{"`index`": issueID},
builder.Eq{"`index`": ref},
cond,
)
priorityIssueIndex = issueID
priorityIssueIndex = ref
}
}
}

View file

@ -4,14 +4,15 @@
package issues
import (
"context"
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
"forgejo.org/modules/optional"
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
func ToSearchOptions(ctx context.Context, keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic,
IsPull: opts.IsPull,
@ -103,5 +104,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.SortBy = SortByUpdatedDesc
}
_ = searchOpt.WithKeyword(ctx, keyword)
return searchOpt
}

View file

@ -149,14 +149,9 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
query := elastic.NewBoolQuery()
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
if len(tokens) > 0 {
if len(options.Tokens) != 0 {
q := elastic.NewBoolQuery()
for _, token := range tokens {
for _, token := range options.Tokens {
innerQ := elastic.NewMultiMatchQuery(token.Term, "content", "comments").FieldWithBoost("title", 2.0).TieBreaker(0.5)
if token.Fuzzy {
// If the term is not a phrase use fuzziness set to AUTO

View file

@ -312,7 +312,7 @@ func ParseSortBy(sortBy string, defaultSortBy internal.SortBy) internal.SortBy {
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
indexer := *globalIndexer.Load()
if opts.Keyword == "" {
if len(opts.Tokens) == 0 {
// This is a conservative shortcut.
// If the keyword is empty, db has better (at least not worse) performance to filter issues.
// When the keyword is empty, it tends to listing rather than searching issues.

View file

@ -47,44 +47,48 @@ func TestDBSearchIssues(t *testing.T) {
func searchIssueWithKeyword(t *testing.T) {
tests := []struct {
opts SearchOptions
keyword string
opts *SearchOptions
expectedIDs []int64
}{
{
SearchOptions{
Keyword: "issue2",
"issue2",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{2},
},
{
SearchOptions{
Keyword: "first",
"first",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{1},
},
{
SearchOptions{
Keyword: "for",
"for",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{11, 5, 3, 2, 1},
},
{
SearchOptions{
Keyword: "good",
"good",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{1},
},
}
ctx := t.Context()
for _, test := range tests {
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, test.opts.WithKeyword(ctx, test.keyword))
issueIDs, _, err := SearchIssues(ctx, test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs, test.opts.Keyword)
assert.Equal(t, test.expectedIDs, issueIDs, test.keyword)
}
}
@ -152,7 +156,7 @@ func searchIssueByID(t *testing.T) {
},
{
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: -1}),
opts: *ToSearchOptions(t.Context(), "", &issues.IssuesOptions{AssigneeID: -1}),
expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
},
{

View file

@ -73,7 +73,7 @@ type SearchResult struct {
// It can handle almost all cases, if there is an exception, we can add a new field, like NoLabelOnly.
// Unfortunately, we still use db for the indexer and have to convert between db.NoConditionID and nil for legacy reasons.
type SearchOptions struct {
Keyword string // keyword to search
Tokens []Token
RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories
@ -149,3 +149,28 @@ const (
// but what if the issue belongs to multiple projects?
// Since it's unsupported to search issues with keyword in project page, we don't need to support it.
)
func (s SortBy) ToIssueSort() string {
switch s {
case SortByScore:
return "relevance"
case SortByCreatedDesc:
return "latest"
case SortByCreatedAsc:
return "oldest"
case SortByUpdatedDesc:
return "recentupdate"
case SortByUpdatedAsc:
return "leastupdate"
case SortByCommentsDesc:
return "mostcomment"
case SortByCommentsAsc:
return "leastcomment"
case SortByDeadlineAsc:
return "nearduedate"
case SortByDeadlineDesc:
return "farduedate"
}
return "latest"
}

View file

@ -4,9 +4,15 @@
package internal
import (
"context"
"io"
"strconv"
"strings"
"time"
"forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
)
type BoolOpt int
@ -23,6 +29,11 @@ type Token struct {
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] == '!') {
@ -102,23 +113,159 @@ nextEnd:
return tk, err
}
// Tokenize the keyword
func (o *SearchOptions) Tokens() (tokens []Token, err error) {
if o.Keyword == "" {
return nil, nil
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(o.Keyword)
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 != "" {
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 nil, err
return err
}
return tokens, nil
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
}
}

View file

@ -4,8 +4,13 @@
package internal
import (
"context"
"testing"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/optional"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -252,12 +257,177 @@ var testOpts = []testIssueQueryStringOpt{
func TestIssueQueryString(t *testing.T) {
var opt SearchOptions
ctx := t.Context()
for _, res := range testOpts {
t.Run(opt.Keyword, func(t *testing.T) {
opt.Keyword = res.Keyword
tokens, err := opt.Tokens()
require.NoError(t, err)
assert.Equal(t, res.Results, tokens)
t.Run(res.Keyword, func(t *testing.T) {
require.NoError(t, opt.WithKeyword(ctx, res.Keyword))
assert.Equal(t, res.Results, opt.Tokens)
})
}
}
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestIssueQueryStringWithFilters(t *testing.T) {
// we don't need all the fixures
// insert only one single test user
require.NoError(t, user.CreateUser(t.Context(), &user.User{
ID: 2,
Name: "test",
LowerName: "test",
Email: "test@localhost",
}))
for _, c := range []struct {
Keyword string
Opts *SearchOptions
}{
// Generic Cases
{
Keyword: "modified:>2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:<2025-08-28",
Opts: &SearchOptions{
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:>2025-08-28 modified:<2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "assignee:test",
Opts: &SearchOptions{
AssigneeID: optional.Some(int64(2)),
},
},
{
Keyword: "assignee:test hi",
Opts: &SearchOptions{
AssigneeID: optional.Some(int64(2)),
Tokens: []Token{
{
Term: "hi",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "mentions:test",
Opts: &SearchOptions{
MentionID: optional.Some(int64(2)),
},
},
{
Keyword: "review:test",
Opts: &SearchOptions{
ReviewedID: optional.Some(int64(2)),
},
},
{
Keyword: "author:test",
Opts: &SearchOptions{
PosterID: optional.Some(int64(2)),
},
},
{
Keyword: "sort:updated:asc",
Opts: &SearchOptions{
SortBy: SortByUpdatedAsc,
},
},
{
Keyword: "sort:test",
Opts: &SearchOptions{
SortBy: SortByScore,
},
},
{
Keyword: "test author:test mentions:test modified:<2025-08-28 sort:comments:desc",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "test",
Kind: BoolOptShould,
Fuzzy: true,
},
},
MentionID: optional.Some(int64(2)),
PosterID: optional.Some(int64(2)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
SortBy: SortByCommentsDesc,
},
},
// Edge Cases
{
Keyword: "author:",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "author:testt",
Opts: &SearchOptions{},
},
{
Keyword: "author: test",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Fuzzy: true,
},
{
Term: "test",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "modified:",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "modified:",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
} {
t.Run(c.Keyword, func(t *testing.T) {
opts := &SearchOptions{}
require.NoError(t, opts.WithKeyword(context.Background(), c.Keyword))
assert.Equal(t, c.Opts, opts)
})
}
}

View file

@ -63,6 +63,7 @@ func TestIndexer(t *testing.T, indexer internal.Indexer) {
}()
}
require.NoError(t, c.SearchOptions.WithKeyword(t.Context(), c.Keyword))
result, err := indexer.Search(t.Context(), c.SearchOptions)
require.NoError(t, err)
@ -100,37 +101,33 @@ var cases = []*testIndexerCase{
},
{
Name: "empty keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "",
},
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "whitespace keyword",
SearchOptions: &internal.SearchOptions{
Keyword: " ",
},
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "dangling slash in keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "\\",
},
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "dangling quote in keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "\"",
},
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "empty",
SearchOptions: &internal.SearchOptions{
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
},
SearchOptions: &internal.SearchOptions{},
ExpectedIDs: []int64{},
ExpectedTotal: 0,
},
@ -153,8 +150,8 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002, 1001, 1000},
@ -167,8 +164,8 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hello", "hello world"}},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello world -hi",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002},
@ -181,8 +178,8 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello world",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002, 1001, 1000},
@ -199,8 +196,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4},
},
@ -218,8 +215,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4},
AllPublic: true,
@ -300,8 +297,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2000}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
IncludedLabelIDs: []int64{2000, 2001},
ExcludedLabelIDs: []int64{2003},
},
@ -317,8 +315,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2002}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}},
},
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
IncludedAnyLabelIDs: []int64{2001, 2002},
ExcludedLabelIDs: []int64{2003},
},
@ -581,8 +580,8 @@ var cases = []*testIndexerCase{
},
{
Name: "Index",
SearchOptions: &internal.SearchOptions{
Keyword: "13",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -591,8 +590,9 @@ var cases = []*testIndexerCase{
},
{
Name: "Index with prefix",
SearchOptions: &internal.SearchOptions{
Keyword: "#13",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -605,8 +605,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Title: "re #13", RepoID: 5},
{ID: 1002, Title: "re #1001", Content: "leave 13 alone. - 13", RepoID: 5},
},
SearchOptions: &internal.SearchOptions{
Keyword: "!13",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -621,8 +622,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Index: 103, Title: "Brrr", RepoID: 5},
{ID: 1004, Index: 104, Title: "Brrr", RepoID: 5},
},
SearchOptions: &internal.SearchOptions{
Keyword: "Brrr -101 -103",
SearchOptions: &internal.SearchOptions{
SortBy: internal.SortByScore,
},
ExpectedIDs: []int64{1002, 1004},
@ -797,6 +799,7 @@ type testIndexerCase struct {
Name string
ExtraData []*internal.IndexerData
Keyword string
SearchOptions *internal.SearchOptions
Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal

View file

@ -233,12 +233,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
}
var keywords []string
if options.Keyword != "" {
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
for _, token := range tokens {
if len(options.Tokens) != 0 {
for _, token := range options.Tokens {
if !token.Fuzzy {
// to make it a phrase search, we have to quote the keyword(s)
// https://www.meilisearch.com/docs/reference/api/search#phrase-search

View file

@ -9,6 +9,7 @@ import (
"strings"
"forgejo.org/modules/base"
"forgejo.org/modules/util"
)
type StringUtils struct{}
@ -70,3 +71,11 @@ func (su *StringUtils) EllipsisString(s string, max int) string {
func (su *StringUtils) ToUpper(s string) string {
return strings.ToUpper(s)
}
func (su *StringUtils) RemoveAll(s string, all ...string) string {
return util.RemoveAllStr(s, false, all...)
}
func (su *StringUtils) RemoveAllPrefix(s string, all ...string) string {
return util.RemoveAllStr(s, true, all...)
}

View file

@ -6,6 +6,7 @@ package translation
import (
"context"
"html/template"
"iter"
"sort"
"strings"
"sync"
@ -327,6 +328,16 @@ func (l *locale) PrettyNumber(v any) string {
return l.msgPrinter.Sprintf("%v", number.Decimal(v))
}
func (l *locale) IterWithTr(kvs ...string) iter.Seq2[string, template.HTML] {
return func(yield func(string, template.HTML) bool) {
for i := 0; i < len(kvs); i += 2 {
if !yield(kvs[i], l.TrHTML(kvs[i+1])) {
return
}
}
}
}
func GetPluralRule(l Locale) int {
return GetPluralRuleImpl(l.Language())
}

View file

@ -3,7 +3,11 @@
package util
import "unsafe"
import (
"slices"
"strings"
"unsafe"
)
func isSnakeCaseUpper(c byte) bool {
return 'A' <= c && c <= 'Z'
@ -117,3 +121,37 @@ func ASCIILower(b byte) byte {
}
return b
}
func RemoveAllStr(s string, prefix bool, all ...string) string {
if len(s) == 0 {
return ""
}
sb, first := strings.Builder{}, true
for field := range strings.FieldsSeq(s) {
if hasAny(field, prefix, all...) {
continue
}
if first {
first = false
goto write
}
sb.WriteRune(' ')
write:
sb.WriteString(field)
}
return sb.String()
}
func hasAny(s string, prefix bool, all ...string) bool {
if !prefix {
return slices.Contains(all, s)
}
for _, field := range all {
if strings.HasPrefix(s, field) {
return true
}
}
return false
}

View file

@ -71,3 +71,46 @@ func TestASCIIEqualFold(t *testing.T) {
})
}
}
func TestRemoveAllStr(t *testing.T) {
for name, c := range map[string]struct {
str, res string
prefix bool
all []string
}{
"Empty": {
str: "",
res: "",
all: []string{"hello"},
prefix: false,
},
"Exact": {
str: "hello",
res: "",
all: []string{"hello"},
prefix: false,
},
"One of Two": {
str: "hello world",
res: "world",
all: []string{"hello"},
prefix: false,
},
"Prefix": {
str: "is:open is:closed hello",
res: "hello",
all: []string{"is:"},
prefix: true,
},
"Prefix Multiple": {
str: "is:open is:closed hello has:fun",
res: "hello",
all: []string{"is:", "has:"},
prefix: true,
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, c.res, RemoveAllStr(c.str, c.prefix, c.all...))
})
}
}

View file

@ -56,6 +56,12 @@
"relativetime.2months": "two months ago",
"relativetime.1year": "last year",
"relativetime.2years": "two years ago",
"repo.issues.filter_poster.hint": "Filter by the author",
"repo.issues.filter_assignee.hint": "Filter by assigned user",
"repo.issues.filter_reviewers.hint": "Filter by reviewed user",
"repo.issues.filter_mention.hint": "Filter by mentioned user",
"repo.issues.filter_modified.hint": "Filter by last modified date",
"repo.issues.filter_sort.hint": "Filter by sort type: created/comments/updated/deadline",
"repo.pulls.poster_manage_approval": "Manage approval",
"repo.pulls.poster_requires_approval": "Some workflows are <a href=\"%[1]s\">waiting to be reviewed.</a>",
"repo.pulls.poster_requires_approval.tooltip": "The author of this pull request is not trusted to run workflows triggered by a pull request created from a forked repository or with AGit. The workflows triggered by a `pull_request` event will not run until they are approved.",
@ -93,6 +99,7 @@
"migrate.forgejo.description": "Migrate data from codeberg.org or other Forgejo instances.",
"repo.issue_indexer.title": "Issue Indexer",
"search.milestone_kind": "Search milestones…",
"search.syntax": "Search syntax",
"repo.settings.push_mirror.branch_filter.label": "Branch filter (optional)",
"repo.settings.push_mirror.branch_filter.description": "Branches to be mirrored. Leave blank to mirror all branches. See <a href=\"%[1]s\">%[2]s documentation</a> for syntax. Examples: <code>main, release/*</code>",
"incorrect_root_url": "This Forgejo instance is configured to be served on \"%s\". You are currently viewing Forgejo through a different URL, which may cause parts of the application to break. The canonical URL is controlled by Forgejo admins via the ROOT_URL setting in the app.ini.",

View file

@ -275,7 +275,6 @@ func SearchIssues(ctx *context.APIContext) {
PageSize: limit,
Page: ctx.FormInt("page"),
},
Keyword: keyword,
RepoIDs: repoIDs,
AllPublic: allPublic,
IsPull: isPull,
@ -285,6 +284,11 @@ func SearchIssues(ctx *context.APIContext) {
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
}
if err := searchOpt.WithKeyword(ctx, keyword); err != nil {
ctx.Error(http.StatusInternalServerError, "WithKeyword", err)
return
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
}
@ -519,12 +523,15 @@ func ListIssues(ctx *context.APIContext) {
searchOpt := &issue_indexer.SearchOptions{
Paginator: &listOptions,
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull,
IsClosed: isClosed,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
}
if err := searchOpt.WithKeyword(ctx, keyword); err != nil {
ctx.Error(http.StatusInternalServerError, "WithKeyword", err)
return
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
}

View file

@ -217,7 +217,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
IssueIDs: nil,
}
if keyword != "" {
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
allIssueIDs, _, err := issueIDsFromSearch(ctx, keyword, statsOpts)
if err != nil {
if issue_indexer.IsAvailable(ctx) {
ctx.ServerError("issueIDsFromSearch", err)
@ -285,7 +285,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
var issues issues_model.IssueList
{
ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
ids, opts, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
Paginator: &db.ListOptions{
Page: pager.Paginater.Current(),
PageSize: setting.UI.IssuePagingNum,
@ -316,6 +316,13 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.ServerError("GetIssuesByIDs", err)
return
}
// The values of these parameters may have been changed
// depending on the query syntax
isShowClosed = opts.IsClosed
sortType = opts.SortBy.ToIssueSort()
posterID = opts.PosterID.Value()
assigneeID = opts.AssigneeID.Value()
}
approvalCounts, err := issues.GetApprovalCounts(ctx)
@ -439,6 +446,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
return
}
cleanedKeyword := util.RemoveAllStr(keyword, false, "is:open", "-is:open", "is:closed", "-is:closed", "is:all")
ctx.Data["PinnedIssues"] = pinned
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
ctx.Data["IssueStats"] = issueStats
@ -447,13 +456,13 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["AllCount"] = issueStats.AllCount
linkStr := "?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%d&archived=%t"
ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr,
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels),
url.QueryEscape(cleanedKeyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels),
milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["OpenLink"] = fmt.Sprintf(linkStr,
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels),
url.QueryEscape(cleanedKeyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels),
milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr,
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels),
url.QueryEscape(cleanedKeyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels),
milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["SelLabelIDs"] = labelIDs
ctx.Data["SelectLabels"] = selectLabels
@ -489,12 +498,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["Page"] = pager
}
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
func issueIDsFromSearch(
ctx *context.Context,
keyword string,
opts *issues_model.IssuesOptions,
) ([]int64, *issue_indexer.SearchOptions, error) {
searchOpts := issue_indexer.ToSearchOptions(ctx, keyword, opts)
ids, _, err := issue_indexer.SearchIssues(ctx, searchOpts)
if err != nil {
return nil, fmt.Errorf("SearchIssues: %w", err)
return nil, searchOpts, fmt.Errorf("SearchIssues: %w", err)
}
return ids, nil
return ids, searchOpts, nil
}
// Issues render issues page
@ -2759,7 +2773,6 @@ func SearchIssues(ctx *context.Context) {
Page: ctx.FormInt("page"),
PageSize: limit,
},
Keyword: keyword,
RepoIDs: repoIDs,
AllPublic: allPublic,
IsPull: isPull,
@ -2769,6 +2782,11 @@ func SearchIssues(ctx *context.Context) {
ProjectID: projectID,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
}
if err := searchOpt.WithKeyword(ctx, keyword); err != nil {
log.Error("WithKeyword: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
@ -2932,13 +2950,18 @@ func ListIssues(ctx *context.Context) {
Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull,
IsClosed: isClosed,
ProjectID: projectID,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc),
}
if err := searchOpt.WithKeyword(ctx, keyword); err != nil {
log.Error("WithKeyword: %v", err)
ctx.Error(http.StatusInternalServerError)
return
}
if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since)
}

View file

@ -592,7 +592,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// USING FINAL STATE OF opts FOR A QUERY.
var issues issues_model.IssueList
{
issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(ctx, keyword, opts))
if err != nil {
ctx.ServerError("issueIDsFromSearch", err)
return
@ -613,11 +613,14 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// -------------------------------
// Fill stats to post to ctx.Data.
// -------------------------------
issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts))
searchOpts := issue_indexer.ToSearchOptions(ctx, keyword, opts)
issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, searchOpts)
if err != nil {
ctx.ServerError("getUserIssueStats", err)
return
}
isShowClosed = searchOpts.IsClosed.ValueOrDefault(isShowClosed)
sortType = searchOpts.SortBy.ToIssueSort()
// Will be posted to ctx.Data.
var shownIssues int
@ -670,10 +673,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["PageIsOrgIssues"] = org != nil
state := "open"
if isShowClosed {
ctx.Data["State"] = "closed"
} else {
ctx.Data["State"] = "open"
state = "closed"
}
ctx.Data["State"] = state
ctx.SetFormString("state", state)
if searchOpts.AssigneeID.Has() {
id := strconv.FormatInt(searchOpts.AssigneeID.Value(), 10)
ctx.SetFormString("assignee", id)
}
pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)

View file

@ -82,7 +82,7 @@
<div class="list-header-author ui dropdown jump item user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.author_search_tooltip"}}"
data-search-url="{{if .Milestone}}{{$.RepoLink}}/issues/posters{{else}}{{$.Link}}/posters{{end}}"
data-selected-user-id="{{$.PosterID}}"
data-action-jump-url="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={user_id}{{if $.ShowArchivedLabels}}&archived=true{{end}}"
data-action-jump-url="?q={{StringUtils.RemoveAllPrefix $.Keyword "author:"}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={user_id}{{if $.ShowArchivedLabels}}&archived=true{{end}}"
>
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_poster"}}
@ -108,11 +108,12 @@
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignee"}}">
</div>
<a rel="nofollow" class="{{if not .AssigneeID}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee=&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}}</a>
<a rel="nofollow" class="{{if eq .AssigneeID -1}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee=-1&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee"}}</a>
{{$keyword := StringUtils.RemoveAllPrefix $.Keyword "assignee:"}}
<a rel="nofollow" class="{{if not .AssigneeID}}active selected {{end}}item" href="?q={{$keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee=&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}}</a>
<a rel="nofollow" class="{{if eq .AssigneeID -1}}active selected {{end}}item" href="?q={{$keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee=-1&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee"}}</a>
<div class="divider"></div>
{{range .Assignees}}
<a rel="nofollow" class="{{if eq $.AssigneeID .ID}}active selected{{end}} item tw-flex" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{.ID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">
<a rel="nofollow" class="{{if eq $.AssigneeID .ID}}active selected{{end}} item tw-flex" href="?q={{$keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{.ID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">
{{ctx.AvatarUtils.Avatar . 20}}{{template "repo/search_name" .}}
</a>
{{end}}
@ -146,11 +147,12 @@
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a rel="nofollow" class="{{if or (eq .SortType "relevance") (not .SortType)}}active {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort=relevency&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_sort.relevance"}}</a>
{{$keyword := StringUtils.RemoveAllPrefix $.Keyword "sort:"}}
<a rel="nofollow" class="{{if or (eq .SortType "relevance") (not .SortType)}}active {{end}}item" href="?q={{$keyword}}&type={{$.ViewType}}&sort=relevency&state={{$.State}}&labels={{.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_sort.relevance"}}</a>
{{$o := .}}
{{range $opt := StringUtils.Make "latest" "oldest" "recentupdate" "leastupdate" "mostcomment" "leastcomment" "nearduedate" "farduedate"}}
{{$text := ctx.Locale.Tr (printf "repo.issues.filter_sort.%s" $opt)}}
<a rel="nofollow" class="{{if eq $o.SortType $opt}}active {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$opt}}&state={{$.State}}&labels={{$o.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{$text}}</a>
<a rel="nofollow" class="{{if eq $o.SortType $opt}}active {{end}}item" href="?q={{$keyword}}&type={{$.ViewType}}&sort={{$opt}}&state={{$.State}}&labels={{$o.SelectLabels}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{$text}}</a>
{{end}}
</div>
</div>

View file

@ -18,4 +18,7 @@
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.issue_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{end}}
</div>
{{if not .PageIsMilestones}}
{{template "shared/search/issue/syntax"}}
{{end}}
</form>

View file

@ -0,0 +1,31 @@
<a class="ui icon tw-px-4 tw-py-2 show-modal" data-modal="#search-syntax-modal">{{svg "octicon-question"}}</a>
<dialog id="search-syntax-modal">
<article>
<header>{{ctx.Locale.Tr "search.syntax"}}</header>
<div class="content">
<table class="tw-table-auto">
<tbody>
{{range $filter, $tr :=
ctx.Locale.IterWithTr
"is:open" "repo.issues.open_title"
"is:closed" "repo.issues.closed_title"
"is:all" "repo.issues.all_title"
"author:<username>" "repo.issues.filter_poster.hint"
"assignee:<username>" "repo.issues.filter_assignee.hint"
"review:<username>" "repo.issues.filter_reviewers.hint"
"mentions:<username>" "repo.issues.filter_mention.hint"
"sort:<by>:[asc|desc]" "repo.issues.filter_sort.hint"
"modified:[>|<]<date>" "repo.issues.filter_modified.hint"}}
<tr>
<th class="tw-p-2"><code>{{$filter}}</code></th>
<td class="tw-p-2">{{$tr | SafeHTML}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<footer class="actions">
<button class="ui button icon cancel">{{ctx.Locale.Tr "settings.cancel"}}</button>
</footer>
</article>
</dialog>

View file

@ -5,11 +5,12 @@
{{template "base/alert" .}}
<div class="list-header">
<div class="switch list-header-toggle">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&q={{$.Keyword}}">
{{$keyword := StringUtils.RemoveAll $.Keyword "is:open" "-is:open" "is:closed" "-is:closed" "is:all"}}
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&q={{$keyword}}">
{{svg "octicon-issue-opened" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&q={{$.Keyword}}">
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&q={{$keyword}}">
{{svg "octicon-issue-closed" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</a>
@ -19,12 +20,17 @@
<input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="sort" value="{{$.SortType}}">
<input type="hidden" name="state" value="{{$.State}}">
{{$placeholder := ctx.Locale.Tr "search.issue_kind"}}
{{if .PageIsPulls}}
{{template "shared/search/combo" dict "Value" $.Keyword "Placeholder" (ctx.Locale.Tr "search.pull_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{else}}
{{template "shared/search/combo" dict "Value" $.Keyword "Placeholder" (ctx.Locale.Tr "search.issue_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{$placeholder = ctx.Locale.Tr "search.pull_kind"}}
{{end}}
{{template "shared/search/combo"
dict
"Value" $.Keyword
"Placeholder" $placeholder
"Tooltip" (ctx.Locale.Tr "explore.go_to")}}
</div>
{{template "shared/search/issue/syntax"}}
</form>
<div class="ui secondary menu tw-mt-0">
<!-- Label -->
@ -73,10 +79,11 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
{{$keyword := StringUtils.RemoveAllPrefix $.Keyword "sort:"}}
{{$o := .}}
{{range $opt := StringUtils.Make "recentupdate" "leastupdate" "latest" "oldest" "mostcomment" "leastcomment" "nearduedate" "farduedate"}}
{{$text := ctx.Locale.Tr (printf "repo.issues.filter_sort.%s" $opt)}}
<a class="{{if or (eq $o.SortType $opt) (and (eq $opt "latest") (not $o.SortType))}}active {{end}}item" href="?type={{$.ViewType}}&sort={{$opt}}&state={{$.State}}&labels={{$o.SelectLabels}}&q={{$.Keyword}}">{{
<a class="{{if or (eq $o.SortType $opt) (and (eq $opt "latest") (not $o.SortType))}}active {{end}}item" href="?type={{$.ViewType}}&sort={{$opt}}&state={{$.State}}&labels={{$o.SelectLabels}}&q={{$keyword}}">{{
$text
}}</a>
{{end}}

View file

@ -108,7 +108,8 @@ func TestViewIssuesSortByType(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
session := loginUser(t, user.Name)
req := NewRequest(t, "GET", repo.Link()+"/issues?type=created_by")
for _, path := range []string{"/issues?type=created_by", "/issues?q=sort:created:asc"} {
req := NewRequest(t, "GET", repo.Link()+path)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
@ -127,6 +128,7 @@ func TestViewIssuesSortByType(t *testing.T) {
issue := getIssue(t, repo.ID, selection)
assert.Equal(t, user.ID, issue.PosterID)
})
}
}
func TestViewIssuesKeyword(t *testing.T) {
@ -192,6 +194,15 @@ func TestViewIssuesSearchOptions(t *testing.T) {
assert.Equal(t, 3, issuesSelection.Length())
})
t.Run("All issues keyword override", func(t *testing.T) {
// Keyword should override the state parameter
req := NewRequestf(t, "GET", "%s/issues?q=is:all&state=open", repo.Link())
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issuesSelection := getIssuesSelection(t, htmlDoc)
assert.Equal(t, 3, issuesSelection.Length())
})
t.Run("Issues with no project", func(t *testing.T) {
req := NewRequestf(t, "GET", "%s/issues?state=all&project=-1", repo.Link())
resp := MakeRequest(t, req, http.StatusOK)
@ -855,13 +866,14 @@ func TestSearchIssues(t *testing.T) {
expectedIssueCount = setting.UI.IssuePagingNum
}
link, _ := url.Parse("/issues/search")
req := NewRequest(t, "GET", link.String())
req := NewRequest(t, "GET", "/issues/search")
resp := session.MakeRequest(t, req, http.StatusOK)
var apiIssues []*api.Issue
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, expectedIssueCount)
t.Run("By Query Params", func(t *testing.T) {
link, _ := url.Parse("/issues/search")
since := "2000-01-01T00:50:01+00:00" // 946687801
before := time.Unix(999307200, 0).Format(time.RFC3339)
query := url.Values{}
@ -939,6 +951,37 @@ func TestSearchIssues(t *testing.T) {
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
})
t.Run("By Keyword", func(t *testing.T) {
link, _ := url.Parse("/issues/search")
for keyword, len := range map[string]int{
"modified:>2000-01-01 modified:<2001-09-02": 11,
"is:closed": 2,
"is:all": 20,
"is:all assignee:user2": 2,
} {
q := url.Values{"q": {keyword}}
link.RawQuery = q.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, len, "keyword: %v", keyword)
}
})
t.Run("Filter + Keyword", func(t *testing.T) {
link, _ := url.Parse("/issues/search")
exec := func(q url.Values, len int) {
link.RawQuery = q.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, len, "query: %v", q.Encode())
}
exec(url.Values{"milestones": {"milestone1"}, "keyword": {"is:all"}}, 1)
exec(url.Values{"assigned": {"true"}, "keyword": {"is:all"}}, 2)
})
}
func TestSearchIssuesWithLabels(t *testing.T) {
@ -985,6 +1028,14 @@ func TestSearchIssuesWithLabels(t *testing.T) {
// org and repo label
query.Set("labels", "label2,orglabel4")
query.Add("q", "is:all")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2)
query.Del("q")
query.Add("state", "all")
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())

View file

@ -18,6 +18,7 @@
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: .5rem;
min-width: 200px; /* to enable flexbox wrapping on mobile */
}