Files
Jonathan Miller a3c6f54ad3 feat(issues): advanced search with custom field filters (#496)
Add the ability to filter issues by custom field values throughout
the entire search stack:

- DB: applyCustomFieldCondition joins custom_field_value with AND
  semantics (all specified fields must match)
- Indexer: CustomFieldFilters map passed through SearchOptions and
  ToDBOptions
- Web: parse cf_{fieldID}={value} query params, show dropdown
  filters in the issue list sidebar for org-level fields
- API: both SearchIssues and ListIssues accept cf_ query params

Closes #496

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 06:06:32 -05:00

114 lines
3.3 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/indexer/issues/internal"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
if opts.IssueIDs != nil {
setting.PanicInDevOrTesting("Indexer SearchOptions doesn't support IssueIDs")
}
searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic,
IsPull: opts.IsPull,
IsClosed: opts.IsClosed,
IsArchived: opts.IsArchived,
}
if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 {
searchOpt.NoLabelOnly = true
} else {
for _, labelID := range opts.LabelIDs {
if labelID > 0 {
searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
} else {
searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
}
}
// opts.IncludedLabelNames and opts.ExcludedLabelNames are not supported here.
// It's not a TO DO, it's just unnecessary.
}
if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID {
searchOpt.MilestoneIDs = []int64{0}
} else {
searchOpt.MilestoneIDs = opts.MilestoneIDs
}
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
searchOpt.NoProjectOnly = true
} else {
searchOpt.ProjectIDs = opts.ProjectIDs
}
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] {
if id > 0 {
return optional.Some(id)
}
if id == db.NoConditionID {
return optional.None[int64]()
}
return nil
}
searchOpt.PosterID = opts.PosterID
searchOpt.MentionID = convertID(opts.MentionedID)
searchOpt.ReviewedID = convertID(opts.ReviewedID)
searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID)
searchOpt.SubscriberID = convertID(opts.SubscriberID)
if opts.UpdatedAfterUnix > 0 {
searchOpt.UpdatedAfterUnix = optional.Some(opts.UpdatedAfterUnix)
}
if opts.UpdatedBeforeUnix > 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(opts.UpdatedBeforeUnix)
}
searchOpt.Paginator = opts.Paginator
searchOpt.CustomFieldFilters = opts.CustomFieldFilters
switch opts.SortType {
case "", "latest":
searchOpt.SortBy = SortByCreatedDesc
case "oldest":
searchOpt.SortBy = SortByCreatedAsc
case "recentupdate":
searchOpt.SortBy = SortByUpdatedDesc
case "leastupdate":
searchOpt.SortBy = SortByUpdatedAsc
case "mostcomment":
searchOpt.SortBy = SortByCommentsDesc
case "leastcomment":
searchOpt.SortBy = SortByCommentsAsc
case "nearduedate":
searchOpt.SortBy = SortByDeadlineAsc
case "farduedate":
searchOpt.SortBy = SortByDeadlineDesc
case "priority", "priorityrepo", "project-column-sorting":
// Unsupported sort type for search
fallthrough
default:
if strings.HasPrefix(opts.SortType, issues_model.ScopeSortPrefix) {
searchOpt.SortBy = internal.SortBy(opts.SortType)
} else {
searchOpt.SortBy = SortByUpdatedDesc
}
}
return searchOpt
}