mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-12-07 14:09:47 +00:00
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:
parent
4d0c7db6cd
commit
255ed593d3
26 changed files with 870 additions and 296 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,39 +155,36 @@ 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
|
||||
q := bleve.NewBooleanQuery()
|
||||
|
||||
tokens, err := options.Tokens()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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),
|
||||
inner_bleve.MatchPhraseQuery(token.Term, "comments", issueIndexerAnalyzer, token.Fuzzy, 1.0))
|
||||
|
||||
if len(tokens) > 0 {
|
||||
q := bleve.NewBooleanQuery()
|
||||
for _, token := range 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),
|
||||
inner_bleve.MatchPhraseQuery(token.Term, "comments", issueIndexerAnalyzer, token.Fuzzy, 1.0))
|
||||
|
||||
if issueID, err := token.ParseIssueReference(); err == nil {
|
||||
idQuery := inner_bleve.NumericEqualityQuery(issueID, "index")
|
||||
idQuery.SetBoost(20.0)
|
||||
innerQ.AddQuery(idQuery)
|
||||
}
|
||||
|
||||
switch token.Kind {
|
||||
case internal.BoolOptMust:
|
||||
q.AddMust(innerQ)
|
||||
case internal.BoolOptShould:
|
||||
q.AddShould(innerQ)
|
||||
case internal.BoolOptNot:
|
||||
q.AddMustNot(innerQ)
|
||||
}
|
||||
if issueID, err := token.ParseIssueReference(); err == nil {
|
||||
idQuery := inner_bleve.NumericEqualityQuery(issueID, "index")
|
||||
idQuery.SetBoost(20.0)
|
||||
innerQ.AddQuery(idQuery)
|
||||
}
|
||||
|
||||
if len(options.Tokens) == 1 {
|
||||
q.AddMust(innerQ)
|
||||
break
|
||||
}
|
||||
|
||||
switch token.Kind {
|
||||
case internal.BoolOptMust:
|
||||
q.AddMust(innerQ)
|
||||
case internal.BoolOptShould:
|
||||
q.AddShould(innerQ)
|
||||
case internal.BoolOptNot:
|
||||
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"))
|
||||
}
|
||||
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"))
|
||||
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.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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
cond = builder.Or(
|
||||
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 {
|
||||
for _, token := range options.Tokens {
|
||||
cond = builder.Or(
|
||||
builder.Eq{"`index`": issueID},
|
||||
cond,
|
||||
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", token.Term),
|
||||
)),
|
||||
),
|
||||
)
|
||||
priorityIssueIndex = issueID
|
||||
|
||||
if ref, err := token.ParseIssueReference(); err != nil {
|
||||
cond = builder.Or(
|
||||
builder.Eq{"`index`": ref},
|
||||
cond,
|
||||
)
|
||||
priorityIssueIndex = ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -99,38 +100,34 @@ var cases = []*testIndexerCase{
|
|||
Expected: allResults,
|
||||
},
|
||||
{
|
||||
Name: "empty keyword",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "",
|
||||
},
|
||||
Expected: allResults,
|
||||
Name: "empty keyword",
|
||||
Keyword: "",
|
||||
SearchOptions: &internal.SearchOptions{},
|
||||
Expected: allResults,
|
||||
},
|
||||
{
|
||||
Name: "whitespace keyword",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: " ",
|
||||
},
|
||||
Expected: allResults,
|
||||
Name: "whitespace keyword",
|
||||
Keyword: " ",
|
||||
SearchOptions: &internal.SearchOptions{},
|
||||
Expected: allResults,
|
||||
},
|
||||
{
|
||||
Name: "dangling slash in keyword",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "\\",
|
||||
},
|
||||
Expected: allResults,
|
||||
Name: "dangling slash in keyword",
|
||||
Keyword: "\\",
|
||||
SearchOptions: &internal.SearchOptions{},
|
||||
Expected: allResults,
|
||||
},
|
||||
{
|
||||
Name: "dangling quote in keyword",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "\"",
|
||||
},
|
||||
Expected: allResults,
|
||||
Name: "dangling quote in keyword",
|
||||
Keyword: "\"",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{},
|
||||
Expected: allResults,
|
||||
},
|
||||
{
|
||||
Name: "empty",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
|
||||
},
|
||||
Name: "empty",
|
||||
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
|
||||
SearchOptions: &internal.SearchOptions{},
|
||||
ExpectedIDs: []int64{},
|
||||
ExpectedTotal: 0,
|
||||
},
|
||||
|
|
@ -153,9 +150,9 @@ var cases = []*testIndexerCase{
|
|||
{ID: 1001, Content: "hi hello world"},
|
||||
{ID: 1002, Comments: []string{"hi", "hello world"}},
|
||||
},
|
||||
Keyword: "hello",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello",
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
},
|
||||
ExpectedIDs: []int64{1002, 1001, 1000},
|
||||
ExpectedTotal: 3,
|
||||
|
|
@ -167,9 +164,9 @@ var cases = []*testIndexerCase{
|
|||
{ID: 1001, Content: "hi hello world"},
|
||||
{ID: 1002, Comments: []string{"hello", "hello world"}},
|
||||
},
|
||||
Keyword: "hello world -hi",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello world -hi",
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
},
|
||||
ExpectedIDs: []int64{1002},
|
||||
ExpectedTotal: 1,
|
||||
|
|
@ -181,9 +178,9 @@ var cases = []*testIndexerCase{
|
|||
{ID: 1001, Content: "hi hello world"},
|
||||
{ID: 1002, Comments: []string{"hi", "hello world"}},
|
||||
},
|
||||
Keyword: "hello world",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello world",
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
SortBy: internal.SortByCreatedDesc,
|
||||
},
|
||||
ExpectedIDs: []int64{1002, 1001, 1000},
|
||||
ExpectedTotal: 3,
|
||||
|
|
@ -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},
|
||||
},
|
||||
Keyword: "hello",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello",
|
||||
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},
|
||||
},
|
||||
Keyword: "hello",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello",
|
||||
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{}},
|
||||
},
|
||||
Keyword: "hello",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello",
|
||||
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{}},
|
||||
},
|
||||
Keyword: "hello",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "hello",
|
||||
IncludedAnyLabelIDs: []int64{2001, 2002},
|
||||
ExcludedLabelIDs: []int64{2003},
|
||||
},
|
||||
|
|
@ -580,9 +579,9 @@ var cases = []*testIndexerCase{
|
|||
},
|
||||
},
|
||||
{
|
||||
Name: "Index",
|
||||
Name: "Index",
|
||||
Keyword: "13",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "13",
|
||||
SortBy: internal.SortByScore,
|
||||
RepoIDs: []int64{5},
|
||||
},
|
||||
|
|
@ -590,9 +589,10 @@ var cases = []*testIndexerCase{
|
|||
ExpectedTotal: 1,
|
||||
},
|
||||
{
|
||||
Name: "Index with prefix",
|
||||
Name: "Index with prefix",
|
||||
Keyword: "#13",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "#13",
|
||||
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},
|
||||
},
|
||||
Keyword: "!13",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "!13",
|
||||
SortBy: internal.SortByScore,
|
||||
RepoIDs: []int64{5},
|
||||
},
|
||||
|
|
@ -621,9 +622,10 @@ var cases = []*testIndexerCase{
|
|||
{ID: 1003, Index: 103, Title: "Brrr", RepoID: 5},
|
||||
{ID: 1004, Index: 104, Title: "Brrr", RepoID: 5},
|
||||
},
|
||||
Keyword: "Brrr -101 -103",
|
||||
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Keyword: "Brrr -101 -103",
|
||||
SortBy: internal.SortByScore,
|
||||
SortBy: internal.SortByScore,
|
||||
},
|
||||
ExpectedIDs: []int64{1002, 1004},
|
||||
ExpectedTotal: 2,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
templates/shared/search/issue/syntax.tmpl
Normal file
31
templates/shared/search/issue/syntax.tmpl
Normal 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>
|
||||
|
|
@ -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}} {{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}} {{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}}
|
||||
|
|
|
|||
|
|
@ -108,25 +108,27 @@ 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")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
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)
|
||||
issuesSelection := getIssuesSelection(t, htmlDoc)
|
||||
expectedNumIssues := unittest.GetCount(t,
|
||||
&issues_model.Issue{RepoID: repo.ID, PosterID: user.ID},
|
||||
unittest.Cond("is_closed=?", false),
|
||||
unittest.Cond("is_pull=?", false),
|
||||
)
|
||||
if expectedNumIssues > setting.UI.IssuePagingNum {
|
||||
expectedNumIssues = setting.UI.IssuePagingNum
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
issuesSelection := getIssuesSelection(t, htmlDoc)
|
||||
expectedNumIssues := unittest.GetCount(t,
|
||||
&issues_model.Issue{RepoID: repo.ID, PosterID: user.ID},
|
||||
unittest.Cond("is_closed=?", false),
|
||||
unittest.Cond("is_pull=?", false),
|
||||
)
|
||||
if 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) {
|
||||
|
|
@ -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,90 +866,122 @@ 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)
|
||||
|
||||
since := "2000-01-01T00:50:01+00:00" // 946687801
|
||||
before := time.Unix(999307200, 0).Format(time.RFC3339)
|
||||
query := url.Values{}
|
||||
query.Add("since", since)
|
||||
query.Add("before", before)
|
||||
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, 11)
|
||||
query.Del("since")
|
||||
query.Del("before")
|
||||
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{}
|
||||
query.Add("since", since)
|
||||
query.Add("before", before)
|
||||
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, 11)
|
||||
query.Del("since")
|
||||
query.Del("before")
|
||||
|
||||
query.Add("state", "closed")
|
||||
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.Add("state", "closed")
|
||||
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.Set("state", "all")
|
||||
link.RawQuery = query.Encode()
|
||||
req = NewRequest(t, "GET", link.String())
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssues)
|
||||
assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
|
||||
assert.Len(t, apiIssues, 20)
|
||||
query.Set("state", "all")
|
||||
link.RawQuery = query.Encode()
|
||||
req = NewRequest(t, "GET", link.String())
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssues)
|
||||
assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
|
||||
assert.Len(t, apiIssues, 20)
|
||||
|
||||
query.Add("limit", "5")
|
||||
link.RawQuery = query.Encode()
|
||||
req = NewRequest(t, "GET", link.String())
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssues)
|
||||
assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
|
||||
assert.Len(t, apiIssues, 5)
|
||||
query.Add("limit", "5")
|
||||
link.RawQuery = query.Encode()
|
||||
req = NewRequest(t, "GET", link.String())
|
||||
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssues)
|
||||
assert.Equal(t, "22", resp.Header().Get("X-Total-Count"))
|
||||
assert.Len(t, apiIssues, 5)
|
||||
|
||||
query = url.Values{"assigned": {"true"}, "state": {"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 = url.Values{"assigned": {"true"}, "state": {"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 = url.Values{"milestones": {"milestone1"}, "state": {"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, 1)
|
||||
query = url.Values{"milestones": {"milestone1"}, "state": {"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, 1)
|
||||
|
||||
query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"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 = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"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 = url.Values{"owner": {"user2"}} // user
|
||||
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, 8)
|
||||
query = url.Values{"owner": {"user2"}} // user
|
||||
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, 8)
|
||||
|
||||
query = url.Values{"owner": {"org3"}} // organization
|
||||
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, 5)
|
||||
query = url.Values{"owner": {"org3"}} // organization
|
||||
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, 5)
|
||||
|
||||
query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team
|
||||
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 = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team
|
||||
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)
|
||||
})
|
||||
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
min-width: 200px; /* to enable flexbox wrapping on mobile */
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue