From 255ed593d3bdb60b50674562b644d05c600b0660 Mon Sep 17 00:00:00 2001 From: Shiny Nematoda Date: Wed, 19 Nov 2025 16:05:42 +0100 Subject: [PATCH] feat(issue-search): support query syntax (#9109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List of currently supported filters: - `is:open` (or `-is:closed`) - `is:closed` (or `-is:open`) - `is:all` - `author:` - `assignee:` - `review:` - `mentions:` - `modified:[>|<]`, where `` is the last update date. - `sort::[asc|desc]`, where `` 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 Reviewed-by: Gusted Co-authored-by: Shiny Nematoda Co-committed-by: Shiny Nematoda --- build/lint-locale-usage/handle-tmpl.go | 6 + modules/indexer/issues/bleve/bleve.go | 130 +++++----- modules/indexer/issues/db/db.go | 43 ++-- modules/indexer/issues/dboptions.go | 7 +- .../issues/elasticsearch/elasticsearch.go | 9 +- modules/indexer/issues/indexer.go | 2 +- modules/indexer/issues/indexer_test.go | 28 ++- modules/indexer/issues/internal/model.go | 27 ++- modules/indexer/issues/internal/qstring.go | 163 ++++++++++++- .../indexer/issues/internal/qstring_test.go | 180 +++++++++++++- .../indexer/issues/internal/tests/tests.go | 85 +++---- .../indexer/issues/meilisearch/meilisearch.go | 8 +- modules/templates/util_string.go | 9 + modules/translation/translation.go | 11 + modules/util/string.go | 40 +++- modules/util/string_test.go | 43 ++++ options/locale_next/locale_en-US.json | 7 + routers/api/v1/repo/issue.go | 11 +- routers/web/repo/issue.go | 45 +++- routers/web/user/home.go | 19 +- templates/repo/issue/filter_list.tmpl | 14 +- templates/repo/issue/search.tmpl | 3 + templates/shared/search/issue/syntax.tmpl | 31 +++ templates/user/dashboard/issues.tmpl | 19 +- tests/integration/issue_test.go | 225 +++++++++++------- web_src/css/repo/list-header.css | 1 + 26 files changed, 870 insertions(+), 296 deletions(-) create mode 100644 templates/shared/search/issue/syntax.tmpl diff --git a/build/lint-locale-usage/handle-tmpl.go b/build/lint-locale-usage/handle-tmpl.go index 8d03291205..e8d4832f9d 100644 --- a/build/lint-locale-usage/handle-tmpl.go +++ b/build/lint-locale-usage/handle-tmpl.go @@ -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 { diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index cb98f722c5..a7c87c8f47 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -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() } diff --git a/modules/indexer/issues/db/db.go b/modules/indexer/issues/db/db.go index 5f42bce9a1..e35d5bbde3 100644 --- a/modules/indexer/issues/db/db.go +++ b/modules/indexer/issues/db/db.go @@ -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 + } } } diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index d67dc68bfc..059d67174c 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -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 } diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 311e92730e..4a5e667c14 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -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 diff --git a/modules/indexer/issues/indexer.go b/modules/indexer/issues/indexer.go index 446e714735..ae2a647427 100644 --- a/modules/indexer/issues/indexer.go +++ b/modules/indexer/issues/indexer.go @@ -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. diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 21daea3d45..ad27511be1 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -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}, }, { diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index cdd113212d..89134dcac7 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -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" +} diff --git a/modules/indexer/issues/internal/qstring.go b/modules/indexer/issues/internal/qstring.go index 348f7a564b..bfa10fd741 100644 --- a/modules/indexer/issues/internal/qstring.go +++ b/modules/indexer/issues/internal/qstring.go @@ -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::[ asc | desc ], + case token.IsOf("sort:"): + o.SortBy = parseSortBy(token.Term[5:]) + + // modified:[ < | > ]. + // 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 + } } diff --git a/modules/indexer/issues/internal/qstring_test.go b/modules/indexer/issues/internal/qstring_test.go index eb4bdb306f..2ad924076f 100644 --- a/modules/indexer/issues/internal/qstring_test.go +++ b/modules/indexer/issues/internal/qstring_test.go @@ -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) }) } } diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 46014994a0..4466bf25a7 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -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 diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 9c14e4cbd3..82b889f875 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -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 diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go index 2d255e54a7..4efa396763 100644 --- a/modules/templates/util_string.go +++ b/modules/templates/util_string.go @@ -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...) +} diff --git a/modules/translation/translation.go b/modules/translation/translation.go index 17c7cc068b..42441115af 100644 --- a/modules/translation/translation.go +++ b/modules/translation/translation.go @@ -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()) } diff --git a/modules/util/string.go b/modules/util/string.go index ca3d43ec6e..ccf899dc2e 100644 --- a/modules/util/string.go +++ b/modules/util/string.go @@ -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 +} diff --git a/modules/util/string_test.go b/modules/util/string_test.go index 1012ab32a4..76208bed5b 100644 --- a/modules/util/string_test.go +++ b/modules/util/string_test.go @@ -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...)) + }) + } +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 3469671fe3..a6d3ecd970 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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 waiting to be reviewed.", "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 %[2]s documentation for syntax. Examples: main, release/*", "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.", diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 442e109843..3c6c1044c6 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -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) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 29f269a511..df61ef0daf 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -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) } diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 2f0eadf93a..618ce3c608 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -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) diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index bd46b04d39..4d5de43ef7 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -82,7 +82,7 @@ - {{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}} - {{ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee"}} + {{$keyword := StringUtils.RemoveAllPrefix $.Keyword "assignee:"}} + {{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}} + {{ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee"}}
{{range .Assignees}} - + {{ctx.AvatarUtils.Avatar . 20}}{{template "repo/search_name" .}} {{end}} @@ -146,11 +147,12 @@ {{svg "octicon-triangle-down" 14 "dropdown icon"}} diff --git a/templates/repo/issue/search.tmpl b/templates/repo/issue/search.tmpl index e9923b5287..a1e9628a49 100644 --- a/templates/repo/issue/search.tmpl +++ b/templates/repo/issue/search.tmpl @@ -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}} + {{if not .PageIsMilestones}} + {{template "shared/search/issue/syntax"}} + {{end}} diff --git a/templates/shared/search/issue/syntax.tmpl b/templates/shared/search/issue/syntax.tmpl new file mode 100644 index 0000000000..737f386b1b --- /dev/null +++ b/templates/shared/search/issue/syntax.tmpl @@ -0,0 +1,31 @@ +{{svg "octicon-question"}} + +
+
{{ctx.Locale.Tr "search.syntax"}}
+
+ + + {{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:" "repo.issues.filter_poster.hint" + "assignee:" "repo.issues.filter_assignee.hint" + "review:" "repo.issues.filter_reviewers.hint" + "mentions:" "repo.issues.filter_mention.hint" + "sort::[asc|desc]" "repo.issues.filter_sort.hint" + "modified:[>|<]" "repo.issues.filter_modified.hint"}} + + + + + {{end}} + +
{{$filter}}{{$tr | SafeHTML}}
+
+
+ +
+
+
diff --git a/templates/user/dashboard/issues.tmpl b/templates/user/dashboard/issues.tmpl index ba4c213947..4b931cd981 100644 --- a/templates/user/dashboard/issues.tmpl +++ b/templates/user/dashboard/issues.tmpl @@ -5,11 +5,12 @@ {{template "base/alert" .}}
- + {{$keyword := StringUtils.RemoveAll $.Keyword "is:open" "-is:open" "is:closed" "-is:closed" "is:all"}} + {{svg "octicon-issue-opened" 16}} {{ctx.Locale.PrettyNumber .IssueStats.OpenCount}} {{ctx.Locale.Tr "repo.issues.open_title"}} - + {{svg "octicon-issue-closed" 16}} {{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}} {{ctx.Locale.Tr "repo.issues.closed_title"}} @@ -19,12 +20,17 @@ + {{$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")}}
+ {{template "shared/search/issue/syntax"}}