From d52f6d547f5db8062843bc8c51d126d3ab58b98a Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 22:54:37 +0200 Subject: [PATCH] fix(tasks): ignore bucket_id filters when scoping subtask roots to parent convertFiltersToDBFilterCondWithAlias hard-codes the task_buckets.bucket_id column and the sole task_buckets join is keyed on the child (task_buckets.task_id = tasks.id), so rebuilding a bucket_id filter against the parent_tasks alias silently bound it to the child's bucket and could misclassify roots when filtering by bucket with expand=subtasks. Strip bucket_id conditions from the parent-match predicate so a bucket filter no longer constrains the parent. --- pkg/models/task_search.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index a00a2fdcf..468965e7d 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -330,6 +330,34 @@ func cloneTaskFilters(filters []*taskFilter) []*taskFilter { return cloned } +// stripBucketIDFilters returns a copy of filters with every bucket_id condition +// removed (recursing into nested groups and dropping groups left empty). The +// parent-scoped root condition cannot evaluate a bucket_id filter against the +// parent: convertFiltersToDBFilterCondWithAlias hard-codes the task_buckets.bucket_id +// column, and the only task_buckets join is keyed on the child (task_buckets.task_id +// = tasks.id). Keeping it would bind the parent filter to the child's bucket and +// misclassify roots, so a bucket_id filter simply does not constrain the parent. +func stripBucketIDFilters(filters []*taskFilter) []*taskFilter { + stripped := make([]*taskFilter, 0, len(filters)) + for _, f := range filters { + if nested, is := f.value.([]*taskFilter); is { + child := stripBucketIDFilters(nested) + if len(child) == 0 { + continue + } + c := *f + c.value = child + stripped = append(stripped, &c) + continue + } + if f.field == taskPropertyBucketID { + continue + } + stripped = append(stripped, f) + } + return stripped +} + // buildSubtaskRootCondition decides which tasks count as "roots" when expanding // subtasks: a task is a root unless its parent is itself part of this result set. // @@ -355,7 +383,7 @@ func buildSubtaskRootCondition(opts *taskSearchOptions) (builder.Cond, error) { parentMatchesFilter := builder.Cond(builder.Expr("1 = 1")) if len(opts.parsedFilters) > 0 { - parentFilters := cloneTaskFilters(opts.parsedFilters) + parentFilters := stripBucketIDFilters(cloneTaskFilters(opts.parsedFilters)) filterCond, err := convertFiltersToDBFilterCondWithAlias(parentFilters, opts.filterIncludeNulls, "parent_tasks") if err != nil { return nil, err