From a3c6f54ad349a23802b1eebbc81aeff01dd79463 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 5 Jun 2026 00:27:56 -0500 Subject: [PATCH 1/2] 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) --- models/issues/issue_search.go | 20 ++++++- modules/indexer/issues/db/options.go | 2 + modules/indexer/issues/dboptions.go | 1 + modules/indexer/issues/internal/model.go | 2 + routers/api/v1/repo/issue.go | 26 +++++++++ routers/web/repo/issue_list.go | 67 +++++++++++++++++++----- templates/repo/issue/filter_list.tmpl | 28 +++++++++- 7 files changed, 129 insertions(+), 17 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 7a57ecb55b..e05b1101c2 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -49,8 +49,9 @@ type IssuesOptions struct { //nolint:revive // export stutter UpdatedAfterUnix int64 UpdatedBeforeUnix int64 // prioritize issues from this repo - PriorityRepoID int64 - IsArchived optional.Option[bool] + PriorityRepoID int64 + IsArchived optional.Option[bool] + CustomFieldFilters map[int64]string // field_id → required value (AND semantics) Owner *user_model.User // issues permission scope, it could be an organization or a user Team *organization.Team // issues permission scope Doer *user_model.User // issues permission scope @@ -211,6 +212,20 @@ func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { // do not need to apply any condition } +func applyCustomFieldCondition(sess *xorm.Session, opts *IssuesOptions) { + if len(opts.CustomFieldFilters) == 0 { + return + } + // Each filtered field adds a subquery: the issue must have a matching + // custom_field_value row for every specified field (AND semantics). + for fieldID, value := range opts.CustomFieldFilters { + subQuery := builder.Select("entity_id").From("custom_field_value").Where( + builder.Eq{"field_id": fieldID, "value": value}, + ) + sess.And(builder.In("issue.id", subQuery)) + } +} + func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) { if len(opts.RepoIDs) == 1 { opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]} @@ -278,6 +293,7 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) { } applyLabelsCondition(sess, opts) + applyCustomFieldCondition(sess, opts) if opts.Owner != nil { sess.And(repo_model.UserOwnedRepoCond(opts.Owner.ID)) diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index d7e58fe97f..b08fca1615 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -82,6 +82,8 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m Doer: nil, } + opts.CustomFieldFilters = options.CustomFieldFilters + if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 { opts.MilestoneIDs = []int64{db.NoConditionID} } else { diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index 931c91922c..eeee275c1b 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -79,6 +79,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp } searchOpt.Paginator = opts.Paginator + searchOpt.CustomFieldFilters = opts.CustomFieldFilters switch opts.SortType { case "", "latest": diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index b6f25bddb5..d7ff720217 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -114,6 +114,8 @@ type SearchOptions struct { Paginator *db.ListOptions SortBy SortBy // sort by field + + CustomFieldFilters map[int64]string // field_id → required value (AND semantics, DB-only) } // Copy returns a copy of the options. diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index b6c242b105..80f3abd8f4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -289,6 +289,19 @@ func SearchIssues(ctx *context.APIContext) { } } + // Parse custom field filters from cf_{fieldID}={value} query params. + cfFilters := make(map[int64]string) + for key, values := range ctx.Req.URL.Query() { + if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { + if fieldID, parseErr := strconv.ParseInt(after, 10, 64); parseErr == nil { + cfFilters[fieldID] = values[0] + } + } + } + if len(cfFilters) > 0 { + searchOpt.CustomFieldFilters = cfFilters + } + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) if err != nil { ctx.APIErrorInternal(err) @@ -517,6 +530,19 @@ func ListIssues(ctx *context.APIContext) { searchOpt.MentionID = optional.Some(mentionedByID) } + // Parse custom field filters from cf_{fieldID}={value} query params. + cfFilters := make(map[int64]string) + for key, values := range ctx.Req.URL.Query() { + if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { + if fieldID, parseErr := strconv.ParseInt(after, 10, 64); parseErr == nil { + cfFilters[fieldID] = values[0] + } + } + } + if len(cfFilters) > 0 { + searchOpt.CustomFieldFilters = cfFilters + } + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) if err != nil { ctx.APIErrorInternal(err) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index d5f213740a..a1650878c9 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -5,8 +5,11 @@ package repo import ( "bytes" + "encoding/json" + "fmt" "maps" "net/http" + "net/url" "slices" "sort" "strconv" @@ -521,20 +524,55 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels) + // Parse custom field filters from query params (cf_{fieldID}={value}). + customFieldFilters := make(map[int64]string) + for key, values := range ctx.Req.URL.Query() { + if after, ok := strings.CutPrefix(key, "cf_"); ok && len(values) > 0 && values[0] != "" { + if fieldID, err := strconv.ParseInt(after, 10, 64); err == nil { + customFieldFilters[fieldID] = values[0] + } + } + } + + // Load custom field definitions for the filter UI. + customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeIssue) + if cfErr != nil { + log.Error("prepareIssueFilterAndList: GetCustomFieldsByOwner: %v", cfErr) + } + ctx.Data["CustomFieldDefs"] = customFieldDefs + ctx.Data["CustomFieldFilters"] = customFieldFilters + // Build a query string fragment for cf_ params so they survive pagination/sort changes. + cfQuery := make(url.Values) + for fieldID, value := range customFieldFilters { + cfQuery.Set(fmt.Sprintf("cf_%d", fieldID), value) + } + ctx.Data["CustomFieldQueryString"] = cfQuery.Encode() + fieldOptions := make(map[int64][]string) + for _, f := range customFieldDefs { + if f.Options != "" { + var opts []string + if err := json.Unmarshal([]byte(f.Options), &opts); err == nil { + fieldOptions[f.ID] = opts + } + } + } + ctx.Data["CustomFieldOptions"] = fieldOptions + var keywordMatchedIssueIDs []int64 var issueStats *issues_model.IssueStats statsOpts := &issues_model.IssuesOptions{ - RepoIDs: []int64{repo.ID}, - LabelIDs: preparedLabelFilter.SelectedLabelIDs, - MilestoneIDs: mileIDs, - ProjectIDs: projectIDs, - AssigneeID: assigneeID, - MentionedID: mentionedID, - PosterID: posterUserID, - ReviewRequestedID: reviewRequestedID, - ReviewedID: reviewedID, - IsPull: isPullOption, - IssueIDs: nil, + RepoIDs: []int64{repo.ID}, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + MilestoneIDs: mileIDs, + ProjectIDs: projectIDs, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterUserID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsPull: isPullOption, + IssueIDs: nil, + CustomFieldFilters: customFieldFilters, } if keyword != "" { @@ -611,9 +649,10 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectI ProjectIDs: projectIDs, IsClosed: isShowClosed, IsPull: isPullOption, - LabelIDs: preparedLabelFilter.SelectedLabelIDs, - SortType: sortType, - IssueIDs: keywordMatchedIssueIDs, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + SortType: sortType, + IssueIDs: keywordMatchedIssueIDs, + CustomFieldFilters: customFieldFilters, }) if err != nil { ctx.ServerError("DBIndexer.Search", err) diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index 961995127b..7b2141dec8 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -1,6 +1,6 @@ {{$projectIDs := $.ProjectIDs}} {{$projectIDsQuery := SliceUtils.JoinInt64 $projectIDs}} -{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $projectIDsQuery "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} +{{$queryLink := QueryBuild (print "?" $.CustomFieldQueryString) "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $projectIDsQuery "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} {{$showAllProjects := not $projectIDs}} {{$showNoProjectSelected := and (eq (len $projectIDs) 1) (eq (index $projectIDs 0) -1)}} @@ -96,6 +96,32 @@ {{end}} +{{if .CustomFieldDefs}} + +{{$cfFilters := .CustomFieldFilters}} +{{$cfOptions := .CustomFieldOptions}} +{{range $def := .CustomFieldDefs}} + {{$opts := index $cfOptions $def.ID}} + {{if $opts}} + {{$cfKey := printf "cf_%d" $def.ID}} + {{$currentVal := index $cfFilters $def.ID}} + + {{end}} +{{end}} +{{end}} +