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] 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 var gotUnexpectedInvoke *int
ltf, ok := handler.LocaleTrFunctions[funcname] ltf, ok := handler.LocaleTrFunctions[funcname]
if !ok { if !ok {

View file

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

View file

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

View file

@ -4,14 +4,15 @@
package issues package issues
import ( import (
"context"
"forgejo.org/models/db" "forgejo.org/models/db"
issues_model "forgejo.org/models/issues" issues_model "forgejo.org/models/issues"
"forgejo.org/modules/optional" "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{ searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs, RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic, AllPublic: opts.AllPublic,
IsPull: opts.IsPull, IsPull: opts.IsPull,
@ -103,5 +104,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.SortBy = SortByUpdatedDesc searchOpt.SortBy = SortByUpdatedDesc
} }
_ = searchOpt.WithKeyword(ctx, keyword)
return searchOpt 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) { func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
query := elastic.NewBoolQuery() query := elastic.NewBoolQuery()
tokens, err := options.Tokens() if len(options.Tokens) != 0 {
if err != nil {
return nil, err
}
if len(tokens) > 0 {
q := elastic.NewBoolQuery() 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) innerQ := elastic.NewMultiMatchQuery(token.Term, "content", "comments").FieldWithBoost("title", 2.0).TieBreaker(0.5)
if token.Fuzzy { if token.Fuzzy {
// If the term is not a phrase use fuzziness set to AUTO // 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) { func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
indexer := *globalIndexer.Load() indexer := *globalIndexer.Load()
if opts.Keyword == "" { if len(opts.Tokens) == 0 {
// This is a conservative shortcut. // This is a conservative shortcut.
// If the keyword is empty, db has better (at least not worse) performance to filter issues. // 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. // 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) { func searchIssueWithKeyword(t *testing.T) {
tests := []struct { tests := []struct {
opts SearchOptions keyword string
opts *SearchOptions
expectedIDs []int64 expectedIDs []int64
}{ }{
{ {
SearchOptions{ "issue2",
Keyword: "issue2", &SearchOptions{
RepoIDs: []int64{1}, RepoIDs: []int64{1},
}, },
[]int64{2}, []int64{2},
}, },
{ {
SearchOptions{ "first",
Keyword: "first", &SearchOptions{
RepoIDs: []int64{1}, RepoIDs: []int64{1},
}, },
[]int64{1}, []int64{1},
}, },
{ {
SearchOptions{ "for",
Keyword: "for",
&SearchOptions{
RepoIDs: []int64{1}, RepoIDs: []int64{1},
}, },
[]int64{11, 5, 3, 2, 1}, []int64{11, 5, 3, 2, 1},
}, },
{ {
SearchOptions{ "good",
Keyword: "good", &SearchOptions{
RepoIDs: []int64{1}, RepoIDs: []int64{1},
}, },
[]int64{1}, []int64{1},
}, },
} }
ctx := t.Context()
for _, test := range tests { 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) 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. // 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}, 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. // 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. // Unfortunately, we still use db for the indexer and have to convert between db.NoConditionID and nil for legacy reasons.
type SearchOptions struct { type SearchOptions struct {
Keyword string // keyword to search Tokens []Token
RepoIDs []int64 // repository IDs which the issues belong to RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories AllPublic bool // if include all public repositories
@ -149,3 +149,28 @@ const (
// but what if the issue belongs to multiple projects? // 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. // 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 package internal
import ( import (
"context"
"io" "io"
"strconv" "strconv"
"strings" "strings"
"time"
"forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
) )
type BoolOpt int type BoolOpt int
@ -23,6 +29,11 @@ type Token struct {
Fuzzy bool 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) { func (tk *Token) ParseIssueReference() (int64, error) {
term := tk.Term term := tk.Term
if len(term) > 1 && (term[0] == '#' || term[0] == '!') { if len(term) > 1 && (term[0] == '#' || term[0] == '!') {
@ -102,23 +113,159 @@ nextEnd:
return tk, err return tk, err
} }
// Tokenize the keyword type userFilter int
func (o *SearchOptions) Tokens() (tokens []Token, err error) {
if o.Keyword == "" { const (
return nil, nil 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} it := Tokenizer{in: in}
var (
tokens []Token
userNames []string
userFilter []userFilter
)
for token, err := it.next(); err == nil; token, err = it.next() { 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) tokens = append(tokens, token)
} }
} }
if err != nil && err != io.EOF { 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 package internal
import ( import (
"context"
"testing" "testing"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/optional"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -252,12 +257,177 @@ var testOpts = []testIssueQueryStringOpt{
func TestIssueQueryString(t *testing.T) { func TestIssueQueryString(t *testing.T) {
var opt SearchOptions var opt SearchOptions
ctx := t.Context()
for _, res := range testOpts { for _, res := range testOpts {
t.Run(opt.Keyword, func(t *testing.T) { t.Run(res.Keyword, func(t *testing.T) {
opt.Keyword = res.Keyword require.NoError(t, opt.WithKeyword(ctx, res.Keyword))
tokens, err := opt.Tokens() assert.Equal(t, res.Results, opt.Tokens)
require.NoError(t, err) })
assert.Equal(t, res.Results, 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) result, err := indexer.Search(t.Context(), c.SearchOptions)
require.NoError(t, err) require.NoError(t, err)
@ -99,38 +100,34 @@ var cases = []*testIndexerCase{
Expected: allResults, Expected: allResults,
}, },
{ {
Name: "empty keyword", Name: "empty keyword",
SearchOptions: &internal.SearchOptions{ Keyword: "",
Keyword: "", SearchOptions: &internal.SearchOptions{},
}, Expected: allResults,
Expected: allResults,
}, },
{ {
Name: "whitespace keyword", Name: "whitespace keyword",
SearchOptions: &internal.SearchOptions{ Keyword: " ",
Keyword: " ", SearchOptions: &internal.SearchOptions{},
}, Expected: allResults,
Expected: allResults,
}, },
{ {
Name: "dangling slash in keyword", Name: "dangling slash in keyword",
SearchOptions: &internal.SearchOptions{ Keyword: "\\",
Keyword: "\\", SearchOptions: &internal.SearchOptions{},
}, Expected: allResults,
Expected: allResults,
}, },
{ {
Name: "dangling quote in keyword", Name: "dangling quote in keyword",
SearchOptions: &internal.SearchOptions{ Keyword: "\"",
Keyword: "\"",
}, SearchOptions: &internal.SearchOptions{},
Expected: allResults, Expected: allResults,
}, },
{ {
Name: "empty", Name: "empty",
SearchOptions: &internal.SearchOptions{ Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69", SearchOptions: &internal.SearchOptions{},
},
ExpectedIDs: []int64{}, ExpectedIDs: []int64{},
ExpectedTotal: 0, ExpectedTotal: 0,
}, },
@ -153,9 +150,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"}, {ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}}, {ID: 1002, Comments: []string{"hi", "hello world"}},
}, },
Keyword: "hello",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello", SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
}, },
ExpectedIDs: []int64{1002, 1001, 1000}, ExpectedIDs: []int64{1002, 1001, 1000},
ExpectedTotal: 3, ExpectedTotal: 3,
@ -167,9 +164,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"}, {ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hello", "hello world"}}, {ID: 1002, Comments: []string{"hello", "hello world"}},
}, },
Keyword: "hello world -hi",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello world -hi", SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
}, },
ExpectedIDs: []int64{1002}, ExpectedIDs: []int64{1002},
ExpectedTotal: 1, ExpectedTotal: 1,
@ -181,9 +178,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"}, {ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}}, {ID: 1002, Comments: []string{"hi", "hello world"}},
}, },
Keyword: "hello world",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello world", SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
}, },
ExpectedIDs: []int64{1002, 1001, 1000}, ExpectedIDs: []int64{1002, 1001, 1000},
ExpectedTotal: 3, ExpectedTotal: 3,
@ -199,8 +196,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
}, },
Keyword: "hello",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SortBy: internal.SortByCreatedDesc, SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4}, RepoIDs: []int64{1, 4},
}, },
@ -218,8 +215,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
}, },
Keyword: "hello",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SortBy: internal.SortByCreatedDesc, SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4}, RepoIDs: []int64{1, 4},
AllPublic: true, AllPublic: true,
@ -300,8 +297,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2000}}, {ID: 1003, Title: "hello d", LabelIDs: []int64{2000}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}}, {ID: 1004, Title: "hello e", LabelIDs: []int64{}},
}, },
Keyword: "hello",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello",
IncludedLabelIDs: []int64{2000, 2001}, IncludedLabelIDs: []int64{2000, 2001},
ExcludedLabelIDs: []int64{2003}, ExcludedLabelIDs: []int64{2003},
}, },
@ -317,8 +315,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2002}}, {ID: 1003, Title: "hello d", LabelIDs: []int64{2002}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}}, {ID: 1004, Title: "hello e", LabelIDs: []int64{}},
}, },
Keyword: "hello",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "hello",
IncludedAnyLabelIDs: []int64{2001, 2002}, IncludedAnyLabelIDs: []int64{2001, 2002},
ExcludedLabelIDs: []int64{2003}, ExcludedLabelIDs: []int64{2003},
}, },
@ -580,9 +579,9 @@ var cases = []*testIndexerCase{
}, },
}, },
{ {
Name: "Index", Name: "Index",
Keyword: "13",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "13",
SortBy: internal.SortByScore, SortBy: internal.SortByScore,
RepoIDs: []int64{5}, RepoIDs: []int64{5},
}, },
@ -590,9 +589,10 @@ var cases = []*testIndexerCase{
ExpectedTotal: 1, ExpectedTotal: 1,
}, },
{ {
Name: "Index with prefix", Name: "Index with prefix",
Keyword: "#13",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "#13",
SortBy: internal.SortByScore, SortBy: internal.SortByScore,
RepoIDs: []int64{5}, RepoIDs: []int64{5},
}, },
@ -605,8 +605,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Title: "re #13", RepoID: 5}, {ID: 1001, Title: "re #13", RepoID: 5},
{ID: 1002, Title: "re #1001", Content: "leave 13 alone. - 13", RepoID: 5}, {ID: 1002, Title: "re #1001", Content: "leave 13 alone. - 13", RepoID: 5},
}, },
Keyword: "!13",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "!13",
SortBy: internal.SortByScore, SortBy: internal.SortByScore,
RepoIDs: []int64{5}, RepoIDs: []int64{5},
}, },
@ -621,9 +622,10 @@ var cases = []*testIndexerCase{
{ID: 1003, Index: 103, Title: "Brrr", RepoID: 5}, {ID: 1003, Index: 103, Title: "Brrr", RepoID: 5},
{ID: 1004, Index: 104, Title: "Brrr", RepoID: 5}, {ID: 1004, Index: 104, Title: "Brrr", RepoID: 5},
}, },
Keyword: "Brrr -101 -103",
SearchOptions: &internal.SearchOptions{ SearchOptions: &internal.SearchOptions{
Keyword: "Brrr -101 -103", SortBy: internal.SortByScore,
SortBy: internal.SortByScore,
}, },
ExpectedIDs: []int64{1002, 1004}, ExpectedIDs: []int64{1002, 1004},
ExpectedTotal: 2, ExpectedTotal: 2,
@ -797,6 +799,7 @@ type testIndexerCase struct {
Name string Name string
ExtraData []*internal.IndexerData ExtraData []*internal.IndexerData
Keyword string
SearchOptions *internal.SearchOptions SearchOptions *internal.SearchOptions
Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal 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 var keywords []string
if options.Keyword != "" { if len(options.Tokens) != 0 {
tokens, err := options.Tokens() for _, token := range options.Tokens {
if err != nil {
return nil, err
}
for _, token := range tokens {
if !token.Fuzzy { if !token.Fuzzy {
// to make it a phrase search, we have to quote the keyword(s) // to make it a phrase search, we have to quote the keyword(s)
// https://www.meilisearch.com/docs/reference/api/search#phrase-search // https://www.meilisearch.com/docs/reference/api/search#phrase-search

View file

@ -9,6 +9,7 @@ import (
"strings" "strings"
"forgejo.org/modules/base" "forgejo.org/modules/base"
"forgejo.org/modules/util"
) )
type StringUtils struct{} type StringUtils struct{}
@ -70,3 +71,11 @@ func (su *StringUtils) EllipsisString(s string, max int) string {
func (su *StringUtils) ToUpper(s string) string { func (su *StringUtils) ToUpper(s string) string {
return strings.ToUpper(s) 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 ( import (
"context" "context"
"html/template" "html/template"
"iter"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@ -327,6 +328,16 @@ func (l *locale) PrettyNumber(v any) string {
return l.msgPrinter.Sprintf("%v", number.Decimal(v)) 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 { func GetPluralRule(l Locale) int {
return GetPluralRuleImpl(l.Language()) return GetPluralRuleImpl(l.Language())
} }

View file

@ -3,7 +3,11 @@
package util package util
import "unsafe" import (
"slices"
"strings"
"unsafe"
)
func isSnakeCaseUpper(c byte) bool { func isSnakeCaseUpper(c byte) bool {
return 'A' <= c && c <= 'Z' return 'A' <= c && c <= 'Z'
@ -117,3 +121,37 @@ func ASCIILower(b byte) byte {
} }
return b 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.2months": "two months ago",
"relativetime.1year": "last year", "relativetime.1year": "last year",
"relativetime.2years": "two years ago", "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_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": "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.", "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.", "migrate.forgejo.description": "Migrate data from codeberg.org or other Forgejo instances.",
"repo.issue_indexer.title": "Issue Indexer", "repo.issue_indexer.title": "Issue Indexer",
"search.milestone_kind": "Search milestones…", "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.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>", "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.", "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, PageSize: limit,
Page: ctx.FormInt("page"), Page: ctx.FormInt("page"),
}, },
Keyword: keyword,
RepoIDs: repoIDs, RepoIDs: repoIDs,
AllPublic: allPublic, AllPublic: allPublic,
IsPull: isPull, IsPull: isPull,
@ -285,6 +284,11 @@ func SearchIssues(ctx *context.APIContext) {
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc), 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 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) searchOpt.UpdatedAfterUnix = optional.Some(since)
} }
@ -519,12 +523,15 @@ func ListIssues(ctx *context.APIContext) {
searchOpt := &issue_indexer.SearchOptions{ searchOpt := &issue_indexer.SearchOptions{
Paginator: &listOptions, Paginator: &listOptions,
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull, IsPull: isPull,
IsClosed: isClosed, IsClosed: isClosed,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc), 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 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) searchOpt.UpdatedAfterUnix = optional.Some(since)
} }

View file

@ -217,7 +217,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
IssueIDs: nil, IssueIDs: nil,
} }
if keyword != "" { if keyword != "" {
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) allIssueIDs, _, err := issueIDsFromSearch(ctx, keyword, statsOpts)
if err != nil { if err != nil {
if issue_indexer.IsAvailable(ctx) { if issue_indexer.IsAvailable(ctx) {
ctx.ServerError("issueIDsFromSearch", err) ctx.ServerError("issueIDsFromSearch", err)
@ -285,7 +285,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
var issues issues_model.IssueList 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{ Paginator: &db.ListOptions{
Page: pager.Paginater.Current(), Page: pager.Paginater.Current(),
PageSize: setting.UI.IssuePagingNum, PageSize: setting.UI.IssuePagingNum,
@ -316,6 +316,13 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.ServerError("GetIssuesByIDs", err) ctx.ServerError("GetIssuesByIDs", err)
return 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) approvalCounts, err := issues.GetApprovalCounts(ctx)
@ -439,6 +446,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
return return
} }
cleanedKeyword := util.RemoveAllStr(keyword, false, "is:open", "-is:open", "is:closed", "-is:closed", "is:all")
ctx.Data["PinnedIssues"] = pinned ctx.Data["PinnedIssues"] = pinned
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
ctx.Data["IssueStats"] = issueStats ctx.Data["IssueStats"] = issueStats
@ -447,13 +456,13 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["AllCount"] = issueStats.AllCount 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" 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, 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) milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, 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) milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, 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) milestoneID, projectID, assigneeID, posterID, archived)
ctx.Data["SelLabelIDs"] = labelIDs ctx.Data["SelLabelIDs"] = labelIDs
ctx.Data["SelectLabels"] = selectLabels ctx.Data["SelectLabels"] = selectLabels
@ -489,12 +498,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
} }
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) { func issueIDsFromSearch(
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts)) 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 { 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 // Issues render issues page
@ -2759,7 +2773,6 @@ func SearchIssues(ctx *context.Context) {
Page: ctx.FormInt("page"), Page: ctx.FormInt("page"),
PageSize: limit, PageSize: limit,
}, },
Keyword: keyword,
RepoIDs: repoIDs, RepoIDs: repoIDs,
AllPublic: allPublic, AllPublic: allPublic,
IsPull: isPull, IsPull: isPull,
@ -2769,6 +2782,11 @@ func SearchIssues(ctx *context.Context) {
ProjectID: projectID, ProjectID: projectID,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc), 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 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) searchOpt.UpdatedAfterUnix = optional.Some(since)
@ -2932,13 +2950,18 @@ func ListIssues(ctx *context.Context) {
Page: ctx.FormInt("page"), Page: ctx.FormInt("page"),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
}, },
Keyword: keyword,
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
IsPull: isPull, IsPull: isPull,
IsClosed: isClosed, IsClosed: isClosed,
ProjectID: projectID, ProjectID: projectID,
SortBy: issue_indexer.ParseSortBy(ctx.FormString("sort"), issue_indexer.SortByCreatedDesc), 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 { if since != 0 {
searchOpt.UpdatedAfterUnix = optional.Some(since) 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. // USING FINAL STATE OF opts FOR A QUERY.
var issues issues_model.IssueList 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 { if err != nil {
ctx.ServerError("issueIDsFromSearch", err) ctx.ServerError("issueIDsFromSearch", err)
return return
@ -613,11 +613,14 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// ------------------------------- // -------------------------------
// Fill stats to post to ctx.Data. // 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 { if err != nil {
ctx.ServerError("getUserIssueStats", err) ctx.ServerError("getUserIssueStats", err)
return return
} }
isShowClosed = searchOpts.IsClosed.ValueOrDefault(isShowClosed)
sortType = searchOpts.SortBy.ToIssueSort()
// Will be posted to ctx.Data. // Will be posted to ctx.Data.
var shownIssues int var shownIssues int
@ -670,10 +673,16 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["SelectLabels"] = selectedLabels ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["PageIsOrgIssues"] = org != nil ctx.Data["PageIsOrgIssues"] = org != nil
state := "open"
if isShowClosed { if isShowClosed {
ctx.Data["State"] = "closed" state = "closed"
} else { }
ctx.Data["State"] = "open" 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) 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"}}" <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-search-url="{{if .Milestone}}{{$.RepoLink}}/issues/posters{{else}}{{$.Link}}/posters{{end}}"
data-selected-user-id="{{$.PosterID}}" 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"> <span class="text">
{{ctx.Locale.Tr "repo.issues.filter_poster"}} {{ctx.Locale.Tr "repo.issues.filter_poster"}}
@ -108,11 +108,12 @@
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignee"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignee"}}">
</div> </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> {{$keyword := StringUtils.RemoveAllPrefix $.Keyword "assignee:"}}
<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> <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> <div class="divider"></div>
{{range .Assignees}} {{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" .}} {{ctx.AvatarUtils.Avatar . 20}}{{template "repo/search_name" .}}
</a> </a>
{{end}} {{end}}
@ -146,11 +147,12 @@
</span> </span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu"> <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 := .}} {{$o := .}}
{{range $opt := StringUtils.Make "latest" "oldest" "recentupdate" "leastupdate" "mostcomment" "leastcomment" "nearduedate" "farduedate"}} {{range $opt := StringUtils.Make "latest" "oldest" "recentupdate" "leastupdate" "mostcomment" "leastcomment" "nearduedate" "farduedate"}}
{{$text := ctx.Locale.Tr (printf "repo.issues.filter_sort.%s" $opt)}} {{$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}} {{end}}
</div> </div>
</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")}} {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.issue_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{end}} {{end}}
</div> </div>
{{if not .PageIsMilestones}}
{{template "shared/search/issue/syntax"}}
{{end}}
</form> </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" .}} {{template "base/alert" .}}
<div class="list-header"> <div class="list-header">
<div class="switch list-header-toggle"> <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}} {{svg "octicon-issue-opened" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}} {{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</a> </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}} {{svg "octicon-issue-closed" 16}}
{{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}} {{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</a> </a>
@ -19,12 +20,17 @@
<input type="hidden" name="type" value="{{$.ViewType}}"> <input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="sort" value="{{$.SortType}}"> <input type="hidden" name="sort" value="{{$.SortType}}">
<input type="hidden" name="state" value="{{$.State}}"> <input type="hidden" name="state" value="{{$.State}}">
{{$placeholder := ctx.Locale.Tr "search.issue_kind"}}
{{if .PageIsPulls}} {{if .PageIsPulls}}
{{template "shared/search/combo" dict "Value" $.Keyword "Placeholder" (ctx.Locale.Tr "search.pull_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}} {{$placeholder = ctx.Locale.Tr "search.pull_kind"}}
{{else}}
{{template "shared/search/combo" dict "Value" $.Keyword "Placeholder" (ctx.Locale.Tr "search.issue_kind") "Tooltip" (ctx.Locale.Tr "explore.go_to")}}
{{end}} {{end}}
{{template "shared/search/combo"
dict
"Value" $.Keyword
"Placeholder" $placeholder
"Tooltip" (ctx.Locale.Tr "explore.go_to")}}
</div> </div>
{{template "shared/search/issue/syntax"}}
</form> </form>
<div class="ui secondary menu tw-mt-0"> <div class="ui secondary menu tw-mt-0">
<!-- Label --> <!-- Label -->
@ -73,10 +79,11 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span> </span>
<div class="menu"> <div class="menu">
{{$keyword := StringUtils.RemoveAllPrefix $.Keyword "sort:"}}
{{$o := .}} {{$o := .}}
{{range $opt := StringUtils.Make "recentupdate" "leastupdate" "latest" "oldest" "mostcomment" "leastcomment" "nearduedate" "farduedate"}} {{range $opt := StringUtils.Make "recentupdate" "leastupdate" "latest" "oldest" "mostcomment" "leastcomment" "nearduedate" "farduedate"}}
{{$text := ctx.Locale.Tr (printf "repo.issues.filter_sort.%s" $opt)}} {{$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 $text
}}</a> }}</a>
{{end}} {{end}}

View file

@ -108,25 +108,27 @@ func TestViewIssuesSortByType(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
session := loginUser(t, user.Name) 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"} {
resp := session.MakeRequest(t, req, http.StatusOK) req := NewRequest(t, "GET", repo.Link()+path)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body) htmlDoc := NewHTMLParser(t, resp.Body)
issuesSelection := getIssuesSelection(t, htmlDoc) issuesSelection := getIssuesSelection(t, htmlDoc)
expectedNumIssues := unittest.GetCount(t, expectedNumIssues := unittest.GetCount(t,
&issues_model.Issue{RepoID: repo.ID, PosterID: user.ID}, &issues_model.Issue{RepoID: repo.ID, PosterID: user.ID},
unittest.Cond("is_closed=?", false), unittest.Cond("is_closed=?", false),
unittest.Cond("is_pull=?", false), unittest.Cond("is_pull=?", false),
) )
if expectedNumIssues > setting.UI.IssuePagingNum { if expectedNumIssues > setting.UI.IssuePagingNum {
expectedNumIssues = setting.UI.IssuePagingNum expectedNumIssues = setting.UI.IssuePagingNum
}
assert.Equal(t, expectedNumIssues, issuesSelection.Length())
issuesSelection.Each(func(_ int, selection *goquery.Selection) {
issue := getIssue(t, repo.ID, selection)
assert.Equal(t, user.ID, issue.PosterID)
})
} }
assert.Equal(t, expectedNumIssues, issuesSelection.Length())
issuesSelection.Each(func(_ int, selection *goquery.Selection) {
issue := getIssue(t, repo.ID, selection)
assert.Equal(t, user.ID, issue.PosterID)
})
} }
func TestViewIssuesKeyword(t *testing.T) { func TestViewIssuesKeyword(t *testing.T) {
@ -192,6 +194,15 @@ func TestViewIssuesSearchOptions(t *testing.T) {
assert.Equal(t, 3, issuesSelection.Length()) 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) { t.Run("Issues with no project", func(t *testing.T) {
req := NewRequestf(t, "GET", "%s/issues?state=all&project=-1", repo.Link()) req := NewRequestf(t, "GET", "%s/issues?state=all&project=-1", repo.Link())
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
@ -855,90 +866,122 @@ func TestSearchIssues(t *testing.T) {
expectedIssueCount = setting.UI.IssuePagingNum expectedIssueCount = setting.UI.IssuePagingNum
} }
link, _ := url.Parse("/issues/search") req := NewRequest(t, "GET", "/issues/search")
req := NewRequest(t, "GET", link.String())
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
var apiIssues []*api.Issue var apiIssues []*api.Issue
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, expectedIssueCount) assert.Len(t, apiIssues, expectedIssueCount)
since := "2000-01-01T00:50:01+00:00" // 946687801 t.Run("By Query Params", func(t *testing.T) {
before := time.Unix(999307200, 0).Format(time.RFC3339) link, _ := url.Parse("/issues/search")
query := url.Values{} since := "2000-01-01T00:50:01+00:00" // 946687801
query.Add("since", since) before := time.Unix(999307200, 0).Format(time.RFC3339)
query.Add("before", before) query := url.Values{}
link.RawQuery = query.Encode() query.Add("since", since)
req = NewRequest(t, "GET", link.String()) query.Add("before", before)
resp = session.MakeRequest(t, req, http.StatusOK) link.RawQuery = query.Encode()
DecodeJSON(t, resp, &apiIssues) req = NewRequest(t, "GET", link.String())
assert.Len(t, apiIssues, 11) resp = session.MakeRequest(t, req, http.StatusOK)
query.Del("since") DecodeJSON(t, resp, &apiIssues)
query.Del("before") assert.Len(t, apiIssues, 11)
query.Del("since")
query.Del("before")
query.Add("state", "closed") query.Add("state", "closed")
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2) assert.Len(t, apiIssues, 2)
query.Set("state", "all") query.Set("state", "all")
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Equal(t, "22", resp.Header().Get("X-Total-Count")) assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 20) assert.Len(t, apiIssues, 20)
query.Add("limit", "5") query.Add("limit", "5")
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Equal(t, "22", resp.Header().Get("X-Total-Count")) assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
assert.Len(t, apiIssues, 5) assert.Len(t, apiIssues, 5)
query = url.Values{"assigned": {"true"}, "state": {"all"}} query = url.Values{"assigned": {"true"}, "state": {"all"}}
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2) assert.Len(t, apiIssues, 2)
query = url.Values{"milestones": {"milestone1"}, "state": {"all"}} query = url.Values{"milestones": {"milestone1"}, "state": {"all"}}
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 1) assert.Len(t, apiIssues, 1)
query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}} query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}}
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2) assert.Len(t, apiIssues, 2)
query = url.Values{"owner": {"user2"}} // user query = url.Values{"owner": {"user2"}} // user
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 8) assert.Len(t, apiIssues, 8)
query = url.Values{"owner": {"org3"}} // organization query = url.Values{"owner": {"org3"}} // organization
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 5) assert.Len(t, apiIssues, 5)
query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiIssues) DecodeJSON(t, resp, &apiIssues)
assert.Len(t, apiIssues, 2) 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) { func TestSearchIssuesWithLabels(t *testing.T) {
@ -985,6 +1028,14 @@ func TestSearchIssuesWithLabels(t *testing.T) {
// org and repo label // org and repo label
query.Set("labels", "label2,orglabel4") 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") query.Add("state", "all")
link.RawQuery = query.Encode() link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String()) req = NewRequest(t, "GET", link.String())

View file

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