fix(kanban): count tasks in bucket, not filter total, for saved-filter bucket limits

On a saved-filter (or view-filter) kanban view, checkBucketLimit counted
the total number of tasks matching the filter instead of the number of
tasks actually in the target bucket. Adding the first task to an empty
limited bucket was therefore wrongly rejected with code 10004
"exceeded the limit", even though the bucket was at 0/limit. The same
setup on a regular project bucket worked because that branch counts
task_buckets rows scoped to the bucket.

Scope the count to the bucket by adding `bucket_id = <id>` to the
TaskCollection filter. ReadAll combines this with the saved-filter /
view filter, so the count reflects exactly the tasks that are in this
bucket and match the filter. This keeps the #355 behaviour (stale
task_buckets rows whose tasks no longer match the filter are excluded)
while fixing the unscoped over-count.

Fixes #2672
This commit is contained in:
kolaente 2026-06-17 19:37:32 +02:00 committed by kolaente
parent 8a255cbff6
commit cf456fb223
2 changed files with 68 additions and 0 deletions

View File

@ -226,6 +226,68 @@ func TestTaskBucket_Update(t *testing.T) {
})
})
t.Run("saved filter: first task into empty limited bucket is allowed", func(t *testing.T) {
// Regression test for #2672: on a saved-filter kanban view the bucket
// limit was checked against the total number of tasks matching the
// filter instead of the number of tasks actually in the target bucket,
// so adding the first task to an empty limited bucket was wrongly
// rejected with ErrBucketLimitExceeded.
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// A saved filter matching many tasks; the filter total is well above
// the bucket limit we set below.
sf := &SavedFilter{
Title: "limit-filter",
Filters: &TaskCollection{Filter: "done = false"},
}
err := sf.Create(s, u)
require.NoError(t, err)
filterProjectID := getProjectIDFromSavedFilterID(sf.ID)
view := &ProjectView{}
exists, err := s.Where("project_id = ? AND view_kind = ?", filterProjectID, ProjectViewKindKanban).Get(view)
require.NoError(t, err)
require.True(t, exists)
// All matching tasks are placed in the default bucket on creation;
// pick three of them to move into a fresh, empty bucket.
var defaultTasks []*TaskBucket
err = s.Where("project_view_id = ?", view.ID).Find(&defaultTasks)
require.NoError(t, err)
require.GreaterOrEqual(t, len(defaultTasks), 3, "filter must match enough tasks to exceed the bucket limit")
limitedBucket := &Bucket{
Title: "limited",
ProjectViewID: view.ID,
ProjectID: filterProjectID,
Limit: 2,
}
err = limitedBucket.Create(s, u)
require.NoError(t, err)
moveTaskToBucket := func(taskID int64) error {
tb := &TaskBucket{
TaskID: taskID,
BucketID: limitedBucket.ID,
ProjectViewID: view.ID,
ProjectID: filterProjectID,
}
return tb.Update(s, u)
}
// Moving the FIRST task into the empty bucket must succeed (0/2 -> 1/2).
require.NoError(t, moveTaskToBucket(defaultTasks[0].TaskID))
// The second one fills the bucket up to the limit (1/2 -> 2/2).
require.NoError(t, moveTaskToBucket(defaultTasks[1].TaskID))
// The third one would exceed the limit and must be rejected.
err = moveTaskToBucket(defaultTasks[2].TaskID)
require.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("keep done timestamp when moving task between projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}

View File

@ -830,9 +830,15 @@ func checkBucketLimit(s *xorm.Session, a web.Auth, t *Task, bucket *Bucket) (tas
}
if view.ProjectID < 0 || (view.Filter != nil && view.Filter.Filter != "") {
// For saved filters or views with a filter, the count must be scoped to
// this bucket *and* the filter: raw task_buckets rows can include tasks
// that no longer match the filter (#355), while the unscoped filter total
// counts tasks across all buckets, not just this one (#2672). ReadAll
// combines the bucket_id condition with the saved-filter / view filter.
tc := &TaskCollection{
ProjectID: view.ProjectID,
ProjectViewID: bucket.ProjectViewID,
Filter: "bucket_id = " + strconv.FormatInt(bucket.ID, 10),
}
_, _, taskCount, err = tc.ReadAll(s, a, "", 1, 1)