From cf456fb2232c7f7fbc0c48a412dd2888b6fb5031 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 19:37:32 +0200 Subject: [PATCH] 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 = ` 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 --- pkg/models/kanban_task_bucket_test.go | 62 +++++++++++++++++++++++++++ pkg/models/tasks.go | 6 +++ 2 files changed, 68 insertions(+) diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..7bde2ade4 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -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} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ee5eda824..eb2989694 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -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)