Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Miller ad4451f23c feat(issues): advanced search with custom field filters (#496)
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
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-05 00:27:56 -05:00
7 changed files with 129 additions and 17 deletions
+18 -2
View File
@@ -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))
+2
View File
@@ -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 {
+1
View File
@@ -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":
+2
View File
@@ -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.
+26
View File
@@ -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)
+53 -14
View File
@@ -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)
+27 -1
View File
@@ -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 @@
</div>
{{end}}
{{if .CustomFieldDefs}}
<!-- Custom Field Filters -->
{{$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}}
<div class="item ui dropdown jump">
<span class="text {{if $currentVal}}tw-font-bold{{end}}">
{{$def.Name}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="{{if not $currentVal}}active {{end}}item" href="{{QueryBuild $queryLink $cfKey NIL}}">All</a>
<div class="divider"></div>
{{range $opt := $opts}}
<a class="{{if eq $opt $currentVal}}active {{end}}item" href="{{QueryBuild $queryLink $cfKey $opt}}">{{$opt}}</a>
{{end}}
</div>
</div>
{{end}}
{{end}}
{{end}}
<!-- Sort -->
<div class="item ui dropdown jump">
<span class="text">