Skip to content

Allow filtering issues by any assignee #33343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions models/db/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,3 @@ const (
// NoConditionID means a condition to filter the records which don't match any id.
// eg: "milestone_id=-1" means "find the items without any milestone.
const NoConditionID int64 = -1

// NonExistingID means a condition to match no result (eg: a non-existing user)
// It doesn't use -1 or -2 because they are used as builtin users.
const NonExistingID int64 = -1000000
31 changes: 15 additions & 16 deletions models/issues/issue_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ type IssuesOptions struct { //nolint
RepoIDs []int64 // overwrites RepoCond if the length is not 0
AllPublic bool // include also all public repositories
RepoCond builder.Cond
AssigneeID optional.Option[int64]
PosterID optional.Option[int64]
AssigneeID string // "(none)" or "(any)" or a user ID
PosterID string // "(none)" or "(any)" or a user ID
MentionedID int64
ReviewRequestedID int64
ReviewedID int64
Expand Down Expand Up @@ -356,26 +356,25 @@ func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_mod
return cond
}

func applyAssigneeCondition(sess *xorm.Session, assigneeID optional.Option[int64]) {
func applyAssigneeCondition(sess *xorm.Session, assigneeID string) {
// old logic: 0 is also treated as "not filtering assignee", because the "assignee" was read as FormInt64
if !assigneeID.Has() || assigneeID.Value() == 0 {
return
}
if assigneeID.Value() == db.NoConditionID {
if assigneeID == "(none)" {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
} else {
} else if assigneeID == "(any)" {
sess.Where("issue.id IN (SELECT issue_id FROM issue_assignees)")
} else if assigneeIDInt64, _ := strconv.ParseInt(assigneeID, 10, 64); assigneeIDInt64 > 0 {
sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
And("issue_assignees.assignee_id = ?", assigneeID.Value())
And("issue_assignees.assignee_id = ?", assigneeIDInt64)
}
}

func applyPosterCondition(sess *xorm.Session, posterID optional.Option[int64]) {
if !posterID.Has() {
return
}
// poster doesn't need to support db.NoConditionID(-1), so just use the value as-is
if posterID.Has() {
sess.And("issue.poster_id=?", posterID.Value())
func applyPosterCondition(sess *xorm.Session, posterID string) {
// Actually every issue has a poster.
// The "(none)" is for internal usage only: when doer tries to search non-existing user as poster, use "(none)" to return empty result.
if posterID == "(none)" {
sess.And("issue.poster_id=0")
} else if posterIDInt64, _ := strconv.ParseInt(posterID, 10, 64); posterIDInt64 > 0 {
sess.And("issue.poster_id=?", posterIDInt64)
}
}

Expand Down
3 changes: 1 addition & 2 deletions models/issues/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -155,7 +154,7 @@ func TestIssues(t *testing.T) {
}{
{
issues_model.IssuesOptions{
AssigneeID: optional.Some(int64(1)),
AssigneeID: "1",
SortType: "oldest",
},
[]int64{1, 6},
Expand Down
18 changes: 14 additions & 4 deletions modules/indexer/issues/bleve/bleve.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package bleve

import (
"context"
"strconv"

"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/util"

"github.com/blevesearch/bleve/v2"
Expand Down Expand Up @@ -246,12 +248,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
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.PosterID != "" {
// "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
queries = append(queries, inner_bleve.NumericEqualityQuery(posterIDInt64, "poster_id"))
}

if options.AssigneeID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id"))
if options.AssigneeID != "" {
if options.AssigneeID == "(any)" {
queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(optional.Some[int64](1), optional.None[int64](), "assignee_id"))
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
queries = append(queries, inner_bleve.NumericEqualityQuery(assigneeIDInt64, "assignee_id"))
}
}

if options.MentionID.Has() {
Expand Down
2 changes: 1 addition & 1 deletion modules/indexer/issues/db/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
RepoIDs: options.RepoIDs,
AllPublic: options.AllPublic,
RepoCond: nil,
AssigneeID: optional.Some(convertID(options.AssigneeID)),
AssigneeID: options.AssigneeID,
PosterID: options.PosterID,
MentionedID: convertID(options.MentionID),
ReviewRequestedID: convertID(options.ReviewRequestedID),
Expand Down
6 changes: 1 addition & 5 deletions modules/indexer/issues/dboptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
}

if opts.AssigneeID.Value() == db.NoConditionID {
searchOpt.AssigneeID = optional.Some[int64](0) // FIXME: this is inconsistent from other places, 0 means "no assignee"
} else if opts.AssigneeID.Value() != 0 {
searchOpt.AssigneeID = opts.AssigneeID
}
searchOpt.AssigneeID = opts.AssigneeID

// See the comment of issues_model.SearchOptions for the reason why we need to convert
convertID := func(id int64) optional.Option[int64] {
Expand Down
18 changes: 14 additions & 4 deletions modules/indexer/issues/elasticsearch/elasticsearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,22 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
}

if options.PosterID.Has() {
query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value()))
if options.PosterID != "" {
// "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.Must(elastic.NewTermQuery("poster_id", posterIDInt64))
}

if options.AssigneeID.Has() {
query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value()))
if options.AssigneeID != "" {
if options.AssigneeID == "(any)" {
q := elastic.NewRangeQuery("assignee_id")
q.Gte(1)
query.Must(q)
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.Must(elastic.NewTermQuery("assignee_id", assigneeIDInt64))
}
}

if options.MentionID.Has() {
Expand Down
31 changes: 27 additions & 4 deletions modules/indexer/issues/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func TestDBSearchIssues(t *testing.T) {
t.Run("search issues with order", searchIssueWithOrder)
t.Run("search issues in project", searchIssueInProject)
t.Run("search issues with paginator", searchIssueWithPaginator)
t.Run("search issues with any assignee", searchIssueWithAnyAssignee)
}

func searchIssueWithKeyword(t *testing.T) {
Expand Down Expand Up @@ -176,19 +177,19 @@ func searchIssueByID(t *testing.T) {
}{
{
opts: SearchOptions{
PosterID: optional.Some(int64(1)),
PosterID: "1",
},
expectedIDs: []int64{11, 6, 3, 2, 1},
},
{
opts: SearchOptions{
AssigneeID: optional.Some(int64(1)),
AssigneeID: "1",
},
expectedIDs: []int64{6, 1},
},
{
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: optional.Some(db.NoConditionID)}),
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it handles the filter correctly
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: "(none)"}),
expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
},
{
Expand Down Expand Up @@ -462,3 +463,25 @@ func searchIssueWithPaginator(t *testing.T) {
assert.Equal(t, test.expectedTotal, total)
}
}

func searchIssueWithAnyAssignee(t *testing.T) {
tests := []struct {
opts SearchOptions
expectedIDs []int64
expectedTotal int64
}{
{
SearchOptions{
AssigneeID: "(any)",
},
[]int64{17, 6, 1},
3,
},
}
for _, test := range tests {
issueIDs, total, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs)
assert.Equal(t, test.expectedTotal, total)
}
}
5 changes: 2 additions & 3 deletions modules/indexer/issues/internal/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,8 @@ type SearchOptions struct {
ProjectID optional.Option[int64] // project the issues belong to
ProjectColumnID optional.Option[int64] // project column the issues belong to

PosterID optional.Option[int64] // poster of the issues

AssigneeID optional.Option[int64] // assignee of the issues, zero means no assignee
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID

MentionID optional.Option[int64] // mentioned user of the issues

Expand Down
21 changes: 18 additions & 3 deletions modules/indexer/issues/internal/tests/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{
PageSize: 5,
},
PosterID: optional.Some(int64(1)),
PosterID: "1",
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
Expand All @@ -397,7 +397,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{
PageSize: 5,
},
AssigneeID: optional.Some(int64(1)),
AssigneeID: "1",
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
Expand All @@ -415,7 +415,7 @@ var cases = []*testIndexerCase{
Paginator: &db.ListOptions{
PageSize: 5,
},
AssigneeID: optional.Some(int64(0)),
AssigneeID: "(none)",
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 5)
Expand Down Expand Up @@ -647,6 +647,21 @@ var cases = []*testIndexerCase{
}
},
},
{
Name: "SearchAnyAssignee",
SearchOptions: &internal.SearchOptions{
AssigneeID: "(any)",
},
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
assert.Len(t, result.Hits, 180)
for _, v := range result.Hits {
assert.GreaterOrEqual(t, data[v.ID].AssigneeID, int64(1))
}
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
return v.AssigneeID >= 1
}), result.Total)
},
},
}

type testIndexerCase struct {
Expand Down
20 changes: 14 additions & 6 deletions modules/indexer/issues/meilisearch/meilisearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,20 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
}

if options.PosterID.Has() {
query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value()))
}

if options.AssigneeID.Has() {
query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value()))
if options.PosterID != "" {
// "(none)" becomes 0, it means no poster
posterIDInt64, _ := strconv.ParseInt(options.PosterID, 10, 64)
query.And(inner_meilisearch.NewFilterEq("poster_id", posterIDInt64))
}

if options.AssigneeID != "" {
if options.AssigneeID == "(any)" {
query.And(inner_meilisearch.NewFilterGte("assignee_id", 1))
} else {
// "(none)" becomes 0, it means no assignee
assigneeIDInt64, _ := strconv.ParseInt(options.AssigneeID, 10, 64)
query.And(inner_meilisearch.NewFilterEq("assignee_id", assigneeIDInt64))
}
}

if options.MentionID.Has() {
Expand Down
4 changes: 2 additions & 2 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1547,8 +1547,8 @@ issues.filter_project = Project
issues.filter_project_all = All projects
issues.filter_project_none = No project
issues.filter_assignee = Assignee
issues.filter_assginee_no_select = All assignees
issues.filter_assginee_no_assignee = No assignee
issues.filter_assginee_no_assignee = Assigned to nobody
issues.filter_assignee_any_assignee = Assigned to anybody
issues.filter_poster = Author
issues.filter_user_placeholder = Search users
issues.filter_user_no_select = All users
Expand Down
8 changes: 4 additions & 4 deletions routers/api/v1/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,10 @@ func SearchIssues(ctx *context.APIContext) {
if ctx.IsSigned {
ctxUserID := ctx.Doer.ID
if ctx.FormBool("created") {
searchOpt.PosterID = optional.Some(ctxUserID)
searchOpt.PosterID = strconv.FormatInt(ctxUserID, 10)
}
if ctx.FormBool("assigned") {
searchOpt.AssigneeID = optional.Some(ctxUserID)
searchOpt.AssigneeID = strconv.FormatInt(ctxUserID, 10)
}
if ctx.FormBool("mentioned") {
searchOpt.MentionID = optional.Some(ctxUserID)
Expand Down Expand Up @@ -538,10 +538,10 @@ func ListIssues(ctx *context.APIContext) {
}

if createdByID > 0 {
searchOpt.PosterID = optional.Some(createdByID)
searchOpt.PosterID = strconv.FormatInt(createdByID, 10)
}
if assignedByID > 0 {
searchOpt.AssigneeID = optional.Some(assignedByID)
searchOpt.AssigneeID = strconv.FormatInt(assignedByID, 10)
}
if mentionedByID > 0 {
searchOpt.MentionID = optional.Some(mentionedByID)
Expand Down
4 changes: 2 additions & 2 deletions routers/web/org/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,11 @@ func ViewProject(ctx *context.Context) {
if ctx.Written() {
return
}
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
assigneeID := ctx.FormString("assignee")

opts := issues_model.IssuesOptions{
LabelIDs: labelIDs,
AssigneeID: optional.Some(assigneeID),
AssigneeID: assigneeID,
Owner: project.Owner,
Doer: ctx.Doer,
}
Expand Down
Loading