From 206975997f2301fd1ae183ac9cbdf63e26901ac5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 5 Sep 2026 11:31:06 +0200 Subject: [PATCH] fix: fall back to full recalculation when updating a task position updateTaskPosition returned ErrNeedsFullRecalculation raw when the neighbours around the target position were too close for a localized repair, surfacing as an HTTP 500 on POST /api/v1/tasks/{id}/position. It now falls back to a full recalculation of the view, like the task creation path and the CLI repair command already do. Ref: Sentry API-CLOUD-1R --- pkg/models/task_position.go | 3 +++ pkg/models/task_position_test.go | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index e168af110..116d90222 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -277,6 +277,9 @@ func updateTaskPosition(s *xorm.Session, a web.Auth, tp *TaskPosition) (err erro if len(conflicts) > 1 { err = resolveTaskPositionConflicts(s, tp.ProjectViewID, conflicts) + if IsErrNeedsFullRecalculation(err) { + err = recalculateTaskPositionsForRepair(s, &ProjectView{ID: tp.ProjectViewID}) + } if err != nil { return err } diff --git a/pkg/models/task_position_test.go b/pkg/models/task_position_test.go index 750544bfd..5942cfdb3 100644 --- a/pkg/models/task_position_test.go +++ b/pkg/models/task_position_test.go @@ -315,6 +315,52 @@ func TestUpdateTaskPositionWithConflictResolution(t *testing.T) { assert.NotEqual(t, pos1.Position, pos2.Position) }) + + t.Run("falls back to full recalculation when neighbours are too close", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + _, err := s.Where("project_view_id = ?", 93).Delete(&TaskPosition{}) + require.NoError(t, err) + + // The gap between the neighbours of the target position only leaves + // 0.01/3 for two conflicting tasks, less than MinPositionSpacing. + conflictPosition := 100.005 + _, err = s.Insert(&TaskPosition{TaskID: 900, ProjectViewID: 93, Position: 100}) + require.NoError(t, err) + _, err = s.Insert(&TaskPosition{TaskID: 901, ProjectViewID: 93, Position: conflictPosition}) + require.NoError(t, err) + _, err = s.Insert(&TaskPosition{TaskID: 902, ProjectViewID: 93, Position: 100.01}) + require.NoError(t, err) + + tp := &TaskPosition{ + TaskID: 903, + ProjectViewID: 93, + Position: conflictPosition, + } + + err = updateTaskPosition(s, nil, tp) + require.NoError(t, err) + + var positions []*TaskPosition + err = s.Where("project_view_id = ?", 93).OrderBy("position ASC").Find(&positions) + require.NoError(t, err) + require.Len(t, positions, 4) + + for i := 1; i < len(positions); i++ { + assert.Greater(t, positions[i].Position, positions[i-1].Position, + "position of task %d should be greater than the one of task %d", + positions[i].TaskID, positions[i-1].TaskID) + assert.GreaterOrEqual(t, positions[i].Position-positions[i-1].Position, MinPositionSpacing) + } + + updated := &TaskPosition{} + _, err = s.Where("task_id = ? AND project_view_id = ?", 903, 93).Get(updated) + require.NoError(t, err) + assert.InDelta(t, updated.Position, tp.Position, 0, "the returned position should be refreshed") + assert.Greater(t, tp.Position, 1000.0, "positions should have been spread over the full range") + }) } func TestRepairTaskPositions(t *testing.T) {