forgejo/models/project/issue.go
Myers Carpenter 2d4a3e5658 fix(models): deduplicate project sorting values and add unique constraints (#11334)
Project columns and cards use a sorting field for ordering, but nothing prevents duplicate values from being inserted. This causes unpredictable ordering and makes swap-based reordering unsafe. Additionally, the same issue can be added to a project multiple times, which shouldn't be possible.

This PR adds a migration to clean up existing data and enforce three unique constraints:
- (project_id, sorting) on project_board — one sorting value per column per project
- (project_id, issue_id) on project_issue — one card per issue per project
- (project_board_id, sorting) on project_issue — one sorting value per card per column

The migration deduplicates existing rows and reassigns sequential sorting values before adding the constraints.

Changes

- Migration: fix duplicate sorting values in project_board and project_issue, remove duplicate (project_id, issue_id) rows, add three unique constraints
- MoveColumnsOnProject: two-phase swap (negate then set) to avoid constraint collisions
- MoveIssuesOnProjectColumn: three-phase approach with duplicate validation and sorted lock ordering
- UpdateColumn: always persist sorting field (allows setting to 0)
- GetDefaultColumn: query max sorting before auto-creating
- createDefaultColumnsForProject: explicit sequential sorting
- changeProjectStatus: only set ClosedDateUnix when closing

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11334
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Myers Carpenter <myers@maski.org>
Co-committed-by: Myers Carpenter <myers@maski.org>
2026-02-27 16:25:20 +01:00

190 lines
6 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"slices"
"forgejo.org/models/db"
"forgejo.org/modules/log"
"forgejo.org/modules/util"
)
// ProjectIssue saves relation from issue to a project
type ProjectIssue struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"INDEX NOT NULL unique(project_issue)"`
ProjectID int64 `xorm:"INDEX NOT NULL unique(project_issue)"`
// ProjectColumnID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors.
ProjectColumnID int64 `xorm:"'project_board_id' INDEX NOT NULL unique(column_sorting)"`
// the sorting order on the column
Sorting int64 `xorm:"NOT NULL DEFAULT 0 unique(column_sorting)"`
}
func init() {
db.RegisterModel(new(ProjectIssue))
}
func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error {
_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&ProjectIssue{})
return err
}
// NumClosedIssues return counter of closed issues assigned to a project
func (p *Project) NumClosedIssues(ctx context.Context) int {
c, err := db.GetEngine(ctx).Table("project_issue").
Join("INNER", "issue", "project_issue.issue_id=issue.id").
Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
Cols("issue_id").
Count()
if err != nil {
log.Error("NumClosedIssues: %v", err)
return 0
}
return int(c)
}
// NumOpenIssues return counter of open issues assigned to a project
func (p *Project) NumOpenIssues(ctx context.Context) int {
c, err := db.GetEngine(ctx).Table("project_issue").
Join("INNER", "issue", "project_issue.issue_id=issue.id").
Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).
Cols("issue_id").
Count()
if err != nil {
log.Error("NumOpenIssues: %v", err)
return 0
}
return int(c)
}
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column.
// The sortedIssueIDs map keys are sorting positions and values are issue IDs.
// Cards not in the map that already exist in the target column are shifted to
// positions after the highest requested sorting value.
func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueIDs map[int64]int64) error {
if len(sortedIssueIDs) == 0 {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
sess := db.GetEngine(ctx)
issueIDs := util.ValuesOfMap(sortedIssueIDs)
// Build reverse map: issueID → sorting and validate no duplicate issue IDs
sortingByIssue := make(map[int64]int64, len(sortedIssueIDs))
for sorting, issueID := range sortedIssueIDs {
sortingByIssue[issueID] = sorting
}
if len(sortingByIssue) != len(sortedIssueIDs) {
return errors.New("duplicate issue IDs in reorder request")
}
// Validate all issues exist and belong to this project
count, err := sess.Table(new(ProjectIssue)).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).Count()
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues must belong to the specified project")
}
// Sort issue IDs to ensure consistent lock ordering across concurrent transactions.
// This prevents deadlocks when multiple transactions update overlapping rows.
slices.Sort(issueIDs)
// Phase 1: Negate sorting for ALL cards currently in the target column
// to free up all positive sorting positions. This prevents collisions
// when moved cards are assigned their final positions.
if _, err := sess.Exec("UPDATE `project_issue` SET sorting = -(sorting + 1) WHERE project_board_id=? AND sorting >= 0",
column.ID); err != nil {
return err
}
// Phase 2: Move the specified cards to the target column with their
// final sorting values. Since all existing cards in the column now have
// negative sorting, there are no collisions.
for _, issueID := range issueIDs {
_, err := sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE project_id=? AND issue_id=?",
column.ID, sortingByIssue[issueID], column.ProjectID, issueID)
if err != nil {
return err
}
}
// Phase 3: Re-pack any remaining cards in the column that still have
// negative sorting (these are pre-existing cards NOT in the move set).
// Assign them positions after the highest requested sorting value.
var maxSorting int64
for sorting := range sortedIssueIDs {
if sorting > maxSorting {
maxSorting = sorting
}
}
var remainingCards []ProjectIssue
if err := sess.Where("project_board_id=? AND sorting < 0", column.ID).
OrderBy("sorting DESC"). // original order was -(original+1), so DESC gives ascending original order
Find(&remainingCards); err != nil {
return err
}
nextSorting := maxSorting + 1
for _, card := range remainingCards {
if _, err := sess.Exec("UPDATE `project_issue` SET sorting=? WHERE id=?",
nextSorting, card.ID); err != nil {
return err
}
nextSorting++
}
return nil
})
}
func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if c.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")
}
if c.ID == newColumn.ID {
return nil
}
res := struct {
MaxSorting int64
IssueCount int64
}{}
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
Table("project_issue").
Where("project_id=?", newColumn.ProjectID).
And("project_board_id=?", newColumn.ID).
Get(&res); err != nil {
return err
}
issues, err := c.GetIssues(ctx)
if err != nil {
return err
}
if len(issues) == 0 {
return nil
}
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
return db.WithTx(ctx, func(ctx context.Context) error {
for i, issue := range issues {
issue.ProjectColumnID = newColumn.ID
issue.Sorting = nextSorting + int64(i)
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
return err
}
}
return nil
})
}