This commit is contained in:
Lunny Xiao
2026-04-02 20:12:14 -07:00
committed by beardev-in
parent 95d2a05dd8
commit 6827575af8
9 changed files with 158 additions and 294 deletions

View File

@@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
return err
}
if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil {
return err
}
@@ -257,26 +257,12 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
return columns, nil
}
// CountColumns returns the total number of columns for a project
func (p *Project) CountColumns(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where("project_id=?", p.ID).Count(&Column{})
}
// GetColumnsPaginated fetches a page of columns for a project
func (p *Project) GetColumnsPaginated(ctx context.Context, opts db.ListOptions) (ColumnList, error) {
columns := make([]*Column, 0, opts.PageSize)
if err := db.SetSessionPagination(db.GetEngine(ctx), &opts).
Where("project_id=?", p.ID).
OrderBy("sorting, id").
Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
// getDefaultColumn return default column and ensure only one exists
func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) {
// getDefaultColumnWithFallback return default column if one exists
// otherwise return the first column by sorting and set it as default column
func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) {
var column Column
// try to find a column "default=true"
has, err := db.GetEngine(ctx).
Where("project_id=? AND `default` = ?", p.ID, true).
Desc("id").Get(&column)
@@ -287,23 +273,9 @@ func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) {
if has {
return &column, nil
}
return nil, ErrProjectColumnNotExist{ColumnID: 0}
}
// MustDefaultColumn returns the default column for a project.
// If one exists, it is returned
// If none exists, the first column will be elevated to the default column of this project
func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) {
c, err := p.getDefaultColumn(ctx)
if err != nil && !IsErrProjectColumnNotExist(err) {
return nil, err
}
if c != nil {
return c, nil
}
var column Column
has, err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column)
// try to find the first column by sorting
has, err = db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column)
if err != nil {
return nil, err
}
@@ -315,8 +287,24 @@ func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) {
return &column, nil
}
return nil, ErrProjectColumnNotExist{ColumnID: 0}
}
// MustDefaultColumn returns the default column for a project.
// If one exists, it is returned
// If none exists, the first column will be elevated to the default column of this project
// If there is no column, it creates a default column and returns it
func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) {
c, err := p.getDefaultColumnWithFallback(ctx)
if err != nil && !IsErrProjectColumnNotExist(err) {
return nil, err
}
if c != nil {
return c, nil
}
// create a default column if none is found
column = Column{
column := Column{
ProjectID: p.ID,
Default: true,
Title: "Uncategorized",
@@ -349,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
})
}
// UpdateColumnSorting update project column sorting
func UpdateColumnSorting(ctx context.Context, cl ColumnList) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for i := range cl {
if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols(
"sorting",
).Update(cl[i]); err != nil {
return err
}
}
return nil
})
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if len(columnsIDs) == 0 {

View File

@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"code.gitea.io/gitea/models/db"
)
// CountColumns returns the total number of columns for a project
func CountProjectColumns(ctx context.Context, projectID int64) (int64, error) {
return db.GetEngine(ctx).Where("project_id=?", projectID).Count(&Column{})
}
// GetProjectColumns returns a list of columns for a project with pagination
func GetProjectColumns(ctx context.Context, projectID int64, opts db.ListOptions) (ColumnList, error) {
columns := make([]*Column, 0, opts.PageSize)
s := db.GetEngine(ctx).Where("project_id=?", projectID).OrderBy("sorting, id")
if !opts.IsListAll() {
db.SetSessionPagination(s, &opts)
}
if err := s.Find(&columns); err != nil {
return nil, err
}
return columns, nil
}
func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) {
columns := make([]*Column, 0, 5)
if len(columnsIDs) == 0 {
return columns, nil
}
if err := db.GetEngine(ctx).
Where("project_id =?", projectID).
In("id", columnsIDs).
OrderBy("sorting").Find(&columns); err != nil {
return nil, err
}
return columns, nil
}

View File

@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestCountColumns(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
count, err := CountProjectColumns(t.Context(), project.ID)
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
}
func TestGetColumnsPaginated(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
// Page 1, limit 2 — returns first 2 columns
page1, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 1, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page1, 2)
// Page 2, limit 2 — returns remaining column
page2, err := GetProjectColumns(t.Context(), project.ID, db.ListOptions{Page: 2, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page2, 1)
// Page 1 and page 2 together cover all columns with no overlap
allIDs := make(map[int64]bool)
for _, c := range append(page1, page2...) {
assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID)
allIDs[c.ID] = true
}
assert.Len(t, allIDs, 3)
}

View File

@@ -80,7 +80,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting) // even if there is no default sorting, the code should also work
@@ -94,7 +94,7 @@ func Test_MoveColumnsOnProject(t *testing.T) {
})
assert.NoError(t, err)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
@@ -106,7 +106,7 @@ func Test_NewColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
columns, err := GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
@@ -124,39 +124,3 @@ func Test_NewColumn(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "maximum number of columns reached")
}
func TestCountColumns(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
count, err := project.CountColumns(t.Context())
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
}
func TestGetColumnsPaginated(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project, err := GetProjectByID(t.Context(), 1)
assert.NoError(t, err)
// Page 1, limit 2 — returns first 2 columns
page1, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 1, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page1, 2)
// Page 2, limit 2 — returns remaining column
page2, err := project.GetColumnsPaginated(t.Context(), db.ListOptions{Page: 2, PageSize: 2})
assert.NoError(t, err)
assert.Len(t, page2, 1)
// Page 1 and page 2 together cover all columns with no overlap
allIDs := make(map[int64]bool)
for _, c := range append(page1, page2...) {
assert.False(t, allIDs[c.ID], "duplicate column ID %d across pages", c.ID)
allIDs[c.ID] = true
}
assert.Len(t, allIDs, 3)
}

View File

@@ -385,14 +385,14 @@ func ListProjectColumns(ctx *context.APIContext) {
return
}
total, err := project.CountColumns(ctx)
total, err := project_model.CountProjectColumns(ctx, project.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
listOptions := utils.GetListOptions(ctx)
columns, err := project.GetColumnsPaginated(ctx, listOptions)
columns, err := project_model.GetProjectColumns(ctx, project.ID, listOptions)
if err != nil {
ctx.APIErrorInternal(err)
return

View File

@@ -309,7 +309,7 @@ func ViewProject(ctx *context.Context) {
return
}
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return

View File

@@ -33,15 +33,12 @@ type issueSidebarAssigneesData struct {
CandidateAssignees []*user_model.User
}
type issueSidebarProjectCardData struct {
Project *project_model.Project
Columns []*project_model.Column
SelectedColumn *project_model.Column
}
type issueSidebarProjectsData struct {
SelectedProjectIDs []int64
ProjectCards []*issueSidebarProjectCardData
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
// the "selected" fields are only valid when len(SelectedProjectIDs)==1
SelectedProjectColumns []*project_model.Column
SelectedProjectColumn *project_model.Column
OpenProjects []*project_model.Project
ClosedProjects []*project_model.Project
@@ -110,7 +107,7 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository
// A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore.
// For non-creator users, only writers could update some meta (eg: assignees, milestone, project)
// Need to clarify the logic and add some tests in the future
data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived
data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived
if !data.CanModifyIssueOrPull {
return data
}
@@ -171,80 +168,34 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
}
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
if err := d.Issue.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
if d.Issue == nil || d.Issue.Project == nil {
return
}
// Load column mappings for all projects
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
if err != nil {
ctx.ServerError("ProjectColumnMap", err)
return
}
// Build project cards for each project
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
for _, project := range d.Issue.Projects {
columns, err := project.GetColumns(ctx)
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
columns, err := project_model.GetProjectColumns(ctx, d.Issue.Project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
var selectedColumn *project_model.Column
columnID := projectColumnMap[project.ID]
d.ProjectsData.SelectedProjectColumns = columns
columnID, err := d.Issue.ProjectColumnID(ctx)
if err != nil {
ctx.ServerError("ProjectColumnID", err)
return
}
for _, col := range columns {
if col.ID == columnID {
selectedColumn = col
d.ProjectsData.SelectedProjectColumn = col
break
}
}
if selectedColumn == nil {
selectedColumn, err = project.MustDefaultColumn(ctx)
if err != nil {
ctx.ServerError("MustDefaultColumn", err)
return
}
}
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{
Project: project,
Columns: columns,
SelectedColumn: selectedColumn,
})
}
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
for _, card := range d.ProjectsData.ProjectCards {
d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID)
}
}
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
if d.Issue == nil {
return
}
d.retrieveProjectCardsForExistingIssue(ctx)
}
func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) {
allProjects := map[int64]*project_model.Project{}
for _, p := range d.ProjectsData.OpenProjects {
allProjects[p.ID] = p
}
for _, p := range d.ProjectsData.ClosedProjects {
allProjects[p.ID] = p
}
for _, id := range ids {
if project, ok := allProjects[id]; ok {
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project})
}
}
d.ProjectsData.SelectedProjectIDs = ids
}
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
if d.Issue != nil && d.Issue.Project != nil {
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
}
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
}

View File

@@ -293,7 +293,7 @@ func ViewProject(ctx *context.Context) {
return
}
columns, err := project.GetColumns(ctx)
columns, err := project_model.GetProjectColumns(ctx, project.ID, db.ListOptionsAll)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return

View File

@@ -7,9 +7,9 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
@@ -60,7 +60,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, err)
}
columns, err := project1.GetColumns(t.Context())
columns, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columns, 3)
assert.EqualValues(t, 0, columns[0].Sorting)
@@ -80,7 +80,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
})
sess.MakeRequest(t, req, http.StatusOK)
columnsAfter, err := project1.GetColumns(t.Context())
columnsAfter, err := project_model.GetProjectColumns(t.Context(), project1.ID, db.ListOptionsAll)
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
@@ -90,128 +90,6 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}
func TestUpdateIssueProject(t *testing.T) {
defer tests.PrepareTestEnv(t)()
sess := loginUser(t, "user2")
t.Run("AssignAndRemove", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
"id": "1",
})
sess.MakeRequest(t, req, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
"id": "",
})
sess.MakeRequest(t, req, http.StatusOK)
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
})
}
func TestUpdateIssueProjectColumn(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2)
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
assert.EqualValues(t, 1, issue.RepoID)
sess := loginUser(t, "user2")
t.Run("MoveColumn", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
"issue_id": "3",
"id": "3",
})
sess.MakeRequest(t, req, http.StatusOK)
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3})
assert.EqualValues(t, 3, pi.ProjectColumnID)
})
t.Run("InvalidIssueID", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
"issue_id": "0",
"id": "3",
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("WrongRepo", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
"issue_id": "6",
"id": "3",
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("WrongProject", func(t *testing.T) {
project2 := project_model.Project{
Title: "second project on repo1",
RepoID: 1,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
require.NoError(t, project_model.NewProject(t.Context(), &project2))
require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{
Title: "other column",
ProjectID: project2.ID,
}))
columns, err := project2.GetColumns(t.Context())
require.NoError(t, err)
require.NotEmpty(t, columns)
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
"issue_id": "1",
"id": strconv.FormatInt(columns[0].ID, 10),
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
}
func TestIssueSidebarProjectColumn(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3)
sess := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/repo1/issues/4")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
assert.Equal(t, 1, cards.Length())
title := cards.Find("a span.gt-ellipsis")
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
assert.Equal(t, 1, columnCombo.Length())
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
assert.Equal(t, 1, defaultItem.Length())
inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`)
assert.Equal(t, 1, inProgressItem.Length())
doneItem := columnCombo.Find(`.menu .item[data-value="3"]`)
assert.Equal(t, 1, doneItem.Length())
comboVal, exists := columnCombo.Find("input.combo-value").Attr("value")
assert.True(t, exists)
assert.Equal(t, "3", comboVal)
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
sess.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
resp = sess.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
assert.Equal(t, 0, cards.Length())
}
// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
t.Helper()
@@ -311,9 +189,15 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
}
require.NoError(t, project_model.NewProject(t.Context(), &project))
// Get the default column
columns, err := project_model.GetProjectColumns(t.Context(), project.ID, db.ListOptionsAll)
require.NoError(t, err)
require.NotEmpty(t, columns)
defaultColumnID := columns[0].ID
// Add issues to the project
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID))
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID))
sess := loginUser(t, "user1")
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)