summaryrefslogtreecommitdiffstats
path: root/models/project
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--models/project/column.go359
-rw-r--r--models/project/column_test.go128
-rw-r--r--models/project/issue.go143
-rw-r--r--models/project/main_test.go23
-rw-r--r--models/project/project.go451
-rw-r--r--models/project/project_test.go124
-rw-r--r--models/project/template.go45
7 files changed, 1273 insertions, 0 deletions
diff --git a/models/project/column.go b/models/project/column.go
new file mode 100644
index 0000000..222f448
--- /dev/null
+++ b/models/project/column.go
@@ -0,0 +1,359 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+type (
+
+ // CardType is used to represent a project column card type
+ CardType uint8
+
+ // ColumnList is a list of all project columns in a repository
+ ColumnList []*Column
+)
+
+const (
+ // CardTypeTextOnly is a project column card type that is text only
+ CardTypeTextOnly CardType = iota
+
+ // CardTypeImagesAndText is a project column card type that has images and text
+ CardTypeImagesAndText
+)
+
+// ColumnColorPattern is a regexp witch can validate ColumnColor
+var ColumnColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
+
+// Column is used to represent column on a project
+type Column struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string
+ Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific column will be assigned to this column
+ Sorting int8 `xorm:"NOT NULL DEFAULT 0"`
+ Color string `xorm:"VARCHAR(7)"`
+
+ ProjectID int64 `xorm:"INDEX NOT NULL"`
+ CreatorID int64 `xorm:"NOT NULL"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+// TableName return the real table name
+func (Column) TableName() string {
+ return "project_board" // TODO: the legacy table name should be project_column
+}
+
+// NumIssues return counter of all issues assigned to the column
+func (c *Column) NumIssues(ctx context.Context) int {
+ total, err := db.GetEngine(ctx).Table("project_issue").
+ Where("project_id=?", c.ProjectID).
+ And("project_board_id=?", c.ID).
+ GroupBy("issue_id").
+ Cols("issue_id").
+ Count()
+ if err != nil {
+ return 0
+ }
+ return int(total)
+}
+
+func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
+ issues := make([]*ProjectIssue, 0, 5)
+ if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
+ And("project_board_id=?", c.ID).
+ OrderBy("sorting, id").
+ Find(&issues); err != nil {
+ return nil, err
+ }
+ return issues, nil
+}
+
+func init() {
+ db.RegisterModel(new(Column))
+}
+
+// IsCardTypeValid checks if the project column card type is valid
+func IsCardTypeValid(p CardType) bool {
+ switch p {
+ case CardTypeTextOnly, CardTypeImagesAndText:
+ return true
+ default:
+ return false
+ }
+}
+
+func createDefaultColumnsForProject(ctx context.Context, project *Project) error {
+ var items []string
+
+ switch project.TemplateType {
+ case TemplateTypeBugTriage:
+ items = setting.Project.ProjectBoardBugTriageType
+ case TemplateTypeBasicKanban:
+ items = setting.Project.ProjectBoardBasicKanbanType
+ case TemplateTypeNone:
+ fallthrough
+ default:
+ return nil
+ }
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ column := Column{
+ CreatedUnix: timeutil.TimeStampNow(),
+ CreatorID: project.CreatorID,
+ Title: "Backlog",
+ ProjectID: project.ID,
+ Default: true,
+ }
+ if err := db.Insert(ctx, column); err != nil {
+ return err
+ }
+
+ if len(items) == 0 {
+ return nil
+ }
+
+ columns := make([]Column, 0, len(items))
+ for _, v := range items {
+ columns = append(columns, Column{
+ CreatedUnix: timeutil.TimeStampNow(),
+ CreatorID: project.CreatorID,
+ Title: v,
+ ProjectID: project.ID,
+ })
+ }
+
+ return db.Insert(ctx, columns)
+ })
+}
+
+// maxProjectColumns max columns allowed in a project, this should not bigger than 127
+// because sorting is int8 in database
+const maxProjectColumns = 20
+
+// NewColumn adds a new project column to a given project
+func NewColumn(ctx context.Context, column *Column) error {
+ if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
+ return fmt.Errorf("bad color code: %s", column.Color)
+ }
+
+ res := struct {
+ MaxSorting int64
+ ColumnCount int64
+ }{}
+ if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
+ Where("project_id=?", column.ProjectID).Get(&res); err != nil {
+ return err
+ }
+ if res.ColumnCount >= maxProjectColumns {
+ return fmt.Errorf("NewBoard: maximum number of columns reached")
+ }
+ column.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
+ _, err := db.GetEngine(ctx).Insert(column)
+ return err
+}
+
+// DeleteColumnByID removes all issues references to the project column.
+func DeleteColumnByID(ctx context.Context, columnID int64) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ return deleteColumnByID(ctx, columnID)
+ })
+}
+
+func deleteColumnByID(ctx context.Context, columnID int64) error {
+ column, err := GetColumn(ctx, columnID)
+ if err != nil {
+ if IsErrProjectColumnNotExist(err) {
+ return nil
+ }
+
+ return err
+ }
+
+ if column.Default {
+ return fmt.Errorf("deleteColumnByID: cannot delete default column")
+ }
+
+ // move all issues to the default column
+ project, err := GetProjectByID(ctx, column.ProjectID)
+ if err != nil {
+ return err
+ }
+ defaultColumn, err := project.GetDefaultColumn(ctx)
+ if err != nil {
+ return err
+ }
+
+ if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
+ return err
+ }
+
+ if _, err := db.GetEngine(ctx).ID(column.ID).NoAutoCondition().Delete(column); err != nil {
+ return err
+ }
+ return nil
+}
+
+func deleteColumnByProjectID(ctx context.Context, projectID int64) error {
+ _, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Column{})
+ return err
+}
+
+// GetColumn fetches the current column of a project
+func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
+ column := new(Column)
+ has, err := db.GetEngine(ctx).ID(columnID).Get(column)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrProjectColumnNotExist{ColumnID: columnID}
+ }
+
+ return column, nil
+}
+
+// UpdateColumn updates a project column
+func UpdateColumn(ctx context.Context, column *Column) error {
+ var fieldToUpdate []string
+
+ if column.Sorting != 0 {
+ fieldToUpdate = append(fieldToUpdate, "sorting")
+ }
+
+ if column.Title != "" {
+ fieldToUpdate = append(fieldToUpdate, "title")
+ }
+
+ if len(column.Color) != 0 && !ColumnColorPattern.MatchString(column.Color) {
+ return fmt.Errorf("bad color code: %s", column.Color)
+ }
+ fieldToUpdate = append(fieldToUpdate, "color")
+
+ _, err := db.GetEngine(ctx).ID(column.ID).Cols(fieldToUpdate...).Update(column)
+
+ return err
+}
+
+// GetColumns fetches all columns related to a project
+func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
+ columns := make([]*Column, 0, 5)
+ if err := db.GetEngine(ctx).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) {
+ var column Column
+ has, err := db.GetEngine(ctx).
+ Where("project_id=? AND `default` = ?", p.ID, true).
+ Desc("id").Get(&column)
+ if err != nil {
+ return nil, err
+ }
+
+ if has {
+ return &column, nil
+ }
+
+ // create a default column if none is found
+ column = Column{
+ ProjectID: p.ID,
+ Default: true,
+ Title: "Uncategorized",
+ CreatorID: p.CreatorID,
+ }
+ if _, err := db.GetEngine(ctx).Insert(&column); err != nil {
+ return nil, err
+ }
+ return &column, nil
+}
+
+// SetDefaultColumn represents a column for issues not assigned to one
+func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ if _, err := GetColumn(ctx, columnID); err != nil {
+ return err
+ }
+
+ if _, err := db.GetEngine(ctx).Where(builder.Eq{
+ "project_id": projectID,
+ "`default`": true,
+ }).Cols("`default`").Update(&Column{Default: false}); err != nil {
+ return err
+ }
+
+ _, err := db.GetEngine(ctx).ID(columnID).
+ Where(builder.Eq{"project_id": projectID}).
+ Cols("`default`").Update(&Column{Default: true})
+ return err
+ })
+}
+
+// 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 err := db.GetEngine(ctx).
+ Where("project_id =?", projectID).
+ In("id", columnsIDs).
+ OrderBy("sorting").Find(&columns); err != nil {
+ return nil, err
+ }
+ return columns, nil
+}
+
+// MoveColumnsOnProject sorts columns in a project
+func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ sess := db.GetEngine(ctx)
+ columnIDs := util.ValuesOfMap(sortedColumnIDs)
+ movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
+ if err != nil {
+ return err
+ }
+ if len(movedColumns) != len(sortedColumnIDs) {
+ return errors.New("some columns do not exist")
+ }
+
+ for _, column := range movedColumns {
+ if column.ProjectID != project.ID {
+ return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
+ }
+ }
+
+ for sorting, columnID := range sortedColumnIDs {
+ if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
diff --git a/models/project/column_test.go b/models/project/column_test.go
new file mode 100644
index 0000000..b02a5b5
--- /dev/null
+++ b/models/project/column_test.go
@@ -0,0 +1,128 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetDefaultColumn(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ projectWithoutDefault, err := GetProjectByID(db.DefaultContext, 5)
+ require.NoError(t, err)
+
+ // check if default column was added
+ column, err := projectWithoutDefault.GetDefaultColumn(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Equal(t, int64(5), column.ProjectID)
+ assert.Equal(t, "Uncategorized", column.Title)
+
+ projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6)
+ require.NoError(t, err)
+
+ // check if multiple defaults were removed
+ column, err = projectWithMultipleDefaults.GetDefaultColumn(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Equal(t, int64(6), column.ProjectID)
+ assert.Equal(t, int64(9), column.ID)
+
+ // set 8 as default column
+ require.NoError(t, SetDefaultColumn(db.DefaultContext, column.ProjectID, 8))
+
+ // then 9 will become a non-default column
+ column, err = GetColumn(db.DefaultContext, 9)
+ require.NoError(t, err)
+ assert.Equal(t, int64(6), column.ProjectID)
+ assert.False(t, column.Default)
+}
+
+func Test_moveIssuesToAnotherColumn(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ column1 := unittest.AssertExistsAndLoadBean(t, &Column{ID: 1, ProjectID: 1})
+
+ issues, err := column1.GetIssues(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, issues, 1)
+ assert.EqualValues(t, 1, issues[0].ID)
+
+ column2 := unittest.AssertExistsAndLoadBean(t, &Column{ID: 2, ProjectID: 1})
+ issues, err = column2.GetIssues(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, issues, 1)
+ assert.EqualValues(t, 3, issues[0].ID)
+
+ err = column1.moveIssuesToAnotherColumn(db.DefaultContext, column2)
+ require.NoError(t, err)
+
+ issues, err = column1.GetIssues(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Empty(t, issues)
+
+ issues, err = column2.GetIssues(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, issues, 2)
+ assert.EqualValues(t, 3, issues[0].ID)
+ assert.EqualValues(t, 0, issues[0].Sorting)
+ assert.EqualValues(t, 1, issues[1].ID)
+ assert.EqualValues(t, 1, issues[1].Sorting)
+}
+
+func Test_MoveColumnsOnProject(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
+ columns, err := project1.GetColumns(db.DefaultContext)
+ require.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
+ assert.EqualValues(t, 0, columns[1].Sorting)
+ assert.EqualValues(t, 0, columns[2].Sorting)
+
+ err = MoveColumnsOnProject(db.DefaultContext, project1, map[int64]int64{
+ 0: columns[1].ID,
+ 1: columns[2].ID,
+ 2: columns[0].ID,
+ })
+ require.NoError(t, err)
+
+ columnsAfter, err := project1.GetColumns(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, columnsAfter, 3)
+ assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
+ assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
+ assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
+}
+
+func Test_NewColumn(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
+ columns, err := project1.GetColumns(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, columns, 3)
+
+ for i := 0; i < maxProjectColumns-3; i++ {
+ err := NewColumn(db.DefaultContext, &Column{
+ Title: fmt.Sprintf("column-%d", i+4),
+ ProjectID: project1.ID,
+ })
+ require.NoError(t, err)
+ }
+ err = NewColumn(db.DefaultContext, &Column{
+ Title: "column-21",
+ ProjectID: project1.ID,
+ })
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), "maximum number of columns reached"))
+}
diff --git a/models/project/issue.go b/models/project/issue.go
new file mode 100644
index 0000000..3361b53
--- /dev/null
+++ b/models/project/issue.go
@@ -0,0 +1,143 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/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"`
+ ProjectID int64 `xorm:"INDEX"`
+
+ // 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"`
+
+ // the sorting order on the column
+ Sorting int64 `xorm:"NOT NULL DEFAULT 0"`
+}
+
+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
+}
+
+// NumIssues return counter of all issues assigned to a project
+func (p *Project) NumIssues(ctx context.Context) int {
+ c, err := db.GetEngine(ctx).Table("project_issue").
+ Where("project_id=?", p.ID).
+ GroupBy("issue_id").
+ Cols("issue_id").
+ Count()
+ if err != nil {
+ log.Error("NumIssues: %v", err)
+ return 0
+ }
+ return int(c)
+}
+
+// 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
+func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueIDs map[int64]int64) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ sess := db.GetEngine(ctx)
+ issueIDs := util.ValuesOfMap(sortedIssueIDs)
+
+ 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 fmt.Errorf("all issues have to be added to a project first")
+ }
+
+ for sorting, issueID := range sortedIssueIDs {
+ _, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
+ if c.ProjectID != newColumn.ProjectID {
+ return fmt.Errorf("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
+ })
+}
diff --git a/models/project/main_test.go b/models/project/main_test.go
new file mode 100644
index 0000000..f4b2d6f
--- /dev/null
+++ b/models/project/main_test.go
@@ -0,0 +1,23 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models/repo"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{
+ FixtureFiles: []string{
+ "project.yml",
+ "project_board.yml",
+ "project_issue.yml",
+ "repository.yml",
+ },
+ })
+}
diff --git a/models/project/project.go b/models/project/project.go
new file mode 100644
index 0000000..8cebf34
--- /dev/null
+++ b/models/project/project.go
@@ -0,0 +1,451 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+type (
+ // CardConfig is used to identify the type of column card that is being used
+ CardConfig struct {
+ CardType CardType
+ Translation string
+ }
+
+ // Type is used to identify the type of project in question and ownership
+ Type uint8
+)
+
+const (
+ // TypeIndividual is a type of project column that is owned by an individual
+ TypeIndividual Type = iota + 1
+
+ // TypeRepository is a project that is tied to a repository
+ TypeRepository
+
+ // TypeOrganization is a project that is tied to an organisation
+ TypeOrganization
+)
+
+// ErrProjectNotExist represents a "ProjectNotExist" kind of error.
+type ErrProjectNotExist struct {
+ ID int64
+ RepoID int64
+}
+
+// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
+func IsErrProjectNotExist(err error) bool {
+ _, ok := err.(ErrProjectNotExist)
+ return ok
+}
+
+func (err ErrProjectNotExist) Error() string {
+ return fmt.Sprintf("projects does not exist [id: %d]", err.ID)
+}
+
+func (err ErrProjectNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrProjectColumnNotExist represents a "ErrProjectColumnNotExist" kind of error.
+type ErrProjectColumnNotExist struct {
+ ColumnID int64
+}
+
+// IsErrProjectColumnNotExist checks if an error is a ErrProjectColumnNotExist
+func IsErrProjectColumnNotExist(err error) bool {
+ _, ok := err.(ErrProjectColumnNotExist)
+ return ok
+}
+
+func (err ErrProjectColumnNotExist) Error() string {
+ return fmt.Sprintf("project column does not exist [id: %d]", err.ColumnID)
+}
+
+func (err ErrProjectColumnNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// Project represents a project
+type Project struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string `xorm:"INDEX NOT NULL"`
+ Description string `xorm:"TEXT"`
+ OwnerID int64 `xorm:"INDEX"`
+ Owner *user_model.User `xorm:"-"`
+ RepoID int64 `xorm:"INDEX"`
+ Repo *repo_model.Repository `xorm:"-"`
+ CreatorID int64 `xorm:"NOT NULL"`
+ IsClosed bool `xorm:"INDEX"`
+ TemplateType TemplateType `xorm:"'board_type'"` // TODO: rename the column to template_type
+ CardType CardType
+ Type Type
+
+ RenderedContent template.HTML `xorm:"-"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+ ClosedDateUnix timeutil.TimeStamp
+}
+
+// Ghost Project is a project which has been deleted
+const GhostProjectID = -1
+
+func (p *Project) IsGhost() bool {
+ return p.ID == GhostProjectID
+}
+
+func (p *Project) LoadOwner(ctx context.Context) (err error) {
+ if p.Owner != nil {
+ return nil
+ }
+ p.Owner, err = user_model.GetUserByID(ctx, p.OwnerID)
+ return err
+}
+
+func (p *Project) LoadRepo(ctx context.Context) (err error) {
+ if p.RepoID == 0 || p.Repo != nil {
+ return nil
+ }
+ p.Repo, err = repo_model.GetRepositoryByID(ctx, p.RepoID)
+ return err
+}
+
+// Link returns the project's relative URL.
+func (p *Project) Link(ctx context.Context) string {
+ if p.OwnerID > 0 {
+ err := p.LoadOwner(ctx)
+ if err != nil {
+ log.Error("LoadOwner: %v", err)
+ return ""
+ }
+ return fmt.Sprintf("%s/-/projects/%d", p.Owner.HomeLink(), p.ID)
+ }
+ if p.RepoID > 0 {
+ err := p.LoadRepo(ctx)
+ if err != nil {
+ log.Error("LoadRepo: %v", err)
+ return ""
+ }
+ return fmt.Sprintf("%s/projects/%d", p.Repo.Link(), p.ID)
+ }
+ return ""
+}
+
+func (p *Project) IconName() string {
+ if p.IsRepositoryProject() {
+ return "octicon-project"
+ }
+ return "octicon-project-symlink"
+}
+
+func (p *Project) IsOrganizationProject() bool {
+ return p.Type == TypeOrganization
+}
+
+func (p *Project) IsRepositoryProject() bool {
+ return p.Type == TypeRepository
+}
+
+func (p *Project) CanBeAccessedByOwnerRepo(ownerID int64, repo *repo_model.Repository) bool {
+ if p.Type == TypeRepository {
+ return repo != nil && p.RepoID == repo.ID // if a project belongs to a repository, then its OwnerID is 0 and can be ignored
+ }
+ return p.OwnerID == ownerID && p.RepoID == 0
+}
+
+func init() {
+ db.RegisterModel(new(Project))
+}
+
+// GetCardConfig retrieves the types of configurations project column cards could have
+func GetCardConfig() []CardConfig {
+ return []CardConfig{
+ {CardTypeTextOnly, "repo.projects.card_type.text_only"},
+ {CardTypeImagesAndText, "repo.projects.card_type.images_and_text"},
+ }
+}
+
+// IsTypeValid checks if a project type is valid
+func IsTypeValid(p Type) bool {
+ switch p {
+ case TypeIndividual, TypeRepository, TypeOrganization:
+ return true
+ default:
+ return false
+ }
+}
+
+// SearchOptions are options for GetProjects
+type SearchOptions struct {
+ db.ListOptions
+ OwnerID int64
+ RepoID int64
+ IsClosed optional.Option[bool]
+ OrderBy db.SearchOrderBy
+ Type Type
+ Title string
+}
+
+func (opts SearchOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.RepoID > 0 {
+ cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
+ }
+ if opts.IsClosed.Has() {
+ cond = cond.And(builder.Eq{"is_closed": opts.IsClosed.Value()})
+ }
+
+ if opts.Type > 0 {
+ cond = cond.And(builder.Eq{"type": opts.Type})
+ }
+ if opts.OwnerID > 0 {
+ cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
+ }
+
+ if len(opts.Title) != 0 {
+ cond = cond.And(db.BuildCaseInsensitiveLike("title", opts.Title))
+ }
+ return cond
+}
+
+func (opts SearchOptions) ToOrders() string {
+ return opts.OrderBy.String()
+}
+
+func GetSearchOrderByBySortType(sortType string) db.SearchOrderBy {
+ switch sortType {
+ case "oldest":
+ return db.SearchOrderByOldest
+ case "recentupdate":
+ return db.SearchOrderByRecentUpdated
+ case "leastupdate":
+ return db.SearchOrderByLeastUpdated
+ default:
+ return db.SearchOrderByNewest
+ }
+}
+
+// NewProject creates a new Project
+func NewProject(ctx context.Context, p *Project) error {
+ if !IsTemplateTypeValid(p.TemplateType) {
+ p.TemplateType = TemplateTypeNone
+ }
+
+ if !IsCardTypeValid(p.CardType) {
+ p.CardType = CardTypeTextOnly
+ }
+
+ if !IsTypeValid(p.Type) {
+ return util.NewInvalidArgumentErrorf("project type is not valid")
+ }
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ if err := db.Insert(ctx, p); err != nil {
+ return err
+ }
+
+ if p.RepoID > 0 {
+ if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
+ return err
+ }
+ }
+
+ return createDefaultColumnsForProject(ctx, p)
+ })
+}
+
+// GetProjectByID returns the projects in a repository
+func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
+ p := new(Project)
+
+ has, err := db.GetEngine(ctx).ID(id).Get(p)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrProjectNotExist{ID: id}
+ }
+
+ return p, nil
+}
+
+// GetProjectForRepoByID returns the projects in a repository
+func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) {
+ p := new(Project)
+ has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", id, repoID).Get(p)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrProjectNotExist{ID: id}
+ }
+ return p, nil
+}
+
+// UpdateProject updates project properties
+func UpdateProject(ctx context.Context, p *Project) error {
+ if !IsCardTypeValid(p.CardType) {
+ p.CardType = CardTypeTextOnly
+ }
+
+ _, err := db.GetEngine(ctx).ID(p.ID).Cols(
+ "title",
+ "description",
+ "card_type",
+ ).Update(p)
+ return err
+}
+
+func updateRepositoryProjectCount(ctx context.Context, repoID int64) error {
+ if _, err := db.GetEngine(ctx).Exec(builder.Update(
+ builder.Eq{
+ "`num_projects`": builder.Select("count(*)").From("`project`").
+ Where(builder.Eq{"`project`.`repo_id`": repoID}.
+ And(builder.Eq{"`project`.`type`": TypeRepository})),
+ }).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil {
+ return err
+ }
+
+ if _, err := db.GetEngine(ctx).Exec(builder.Update(
+ builder.Eq{
+ "`num_closed_projects`": builder.Select("count(*)").From("`project`").
+ Where(builder.Eq{"`project`.`repo_id`": repoID}.
+ And(builder.Eq{"`project`.`type`": TypeRepository}).
+ And(builder.Eq{"`project`.`is_closed`": true})),
+ }).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil {
+ return err
+ }
+ return nil
+}
+
+// ChangeProjectStatusByRepoIDAndID toggles a project between opened and closed
+func ChangeProjectStatusByRepoIDAndID(ctx context.Context, repoID, projectID int64, isClosed bool) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ p := new(Project)
+
+ has, err := db.GetEngine(ctx).ID(projectID).Where("repo_id = ?", repoID).Get(p)
+ if err != nil {
+ return err
+ } else if !has {
+ return ErrProjectNotExist{ID: projectID, RepoID: repoID}
+ }
+
+ if err := changeProjectStatus(ctx, p, isClosed); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// ChangeProjectStatus toggle a project between opened and closed
+func ChangeProjectStatus(ctx context.Context, p *Project, isClosed bool) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := changeProjectStatus(ctx, p, isClosed); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func changeProjectStatus(ctx context.Context, p *Project, isClosed bool) error {
+ p.IsClosed = isClosed
+ p.ClosedDateUnix = timeutil.TimeStampNow()
+ count, err := db.GetEngine(ctx).ID(p.ID).Where("repo_id = ? AND is_closed = ?", p.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(p)
+ if err != nil {
+ return err
+ }
+ if count < 1 {
+ return nil
+ }
+
+ return updateRepositoryProjectCount(ctx, p.RepoID)
+}
+
+// DeleteProjectByID deletes a project from a repository. if it's not in a database
+// transaction, it will start a new database transaction
+func DeleteProjectByID(ctx context.Context, id int64) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ p, err := GetProjectByID(ctx, id)
+ if err != nil {
+ if IsErrProjectNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ if err := deleteProjectIssuesByProjectID(ctx, id); err != nil {
+ return err
+ }
+
+ if err := deleteColumnByProjectID(ctx, id); err != nil {
+ return err
+ }
+
+ if _, err = db.GetEngine(ctx).ID(p.ID).Delete(new(Project)); err != nil {
+ return err
+ }
+
+ return updateRepositoryProjectCount(ctx, p.RepoID)
+ })
+}
+
+func DeleteProjectByRepoID(ctx context.Context, repoID int64) error {
+ switch {
+ case setting.Database.Type.IsSQLite3():
+ if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_issue WHERE project_issue.id IN (SELECT project_issue.id FROM project_issue INNER JOIN project WHERE project.id = project_issue.project_id AND project.repo_id = ?)", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_board WHERE project_board.id IN (SELECT project_board.id FROM project_board INNER JOIN project WHERE project.id = project_board.project_id AND project.repo_id = ?)", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Table("project").Where("repo_id = ? ", repoID).Delete(&Project{}); err != nil {
+ return err
+ }
+ case setting.Database.Type.IsPostgreSQL():
+ if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_issue USING project WHERE project.id = project_issue.project_id AND project.repo_id = ? ", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_board USING project WHERE project.id = project_board.project_id AND project.repo_id = ? ", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Table("project").Where("repo_id = ? ", repoID).Delete(&Project{}); err != nil {
+ return err
+ }
+ default:
+ if _, err := db.GetEngine(ctx).Exec("DELETE project_issue FROM project_issue INNER JOIN project ON project.id = project_issue.project_id WHERE project.repo_id = ? ", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Exec("DELETE project_board FROM project_board INNER JOIN project ON project.id = project_board.project_id WHERE project.repo_id = ? ", repoID); err != nil {
+ return err
+ }
+ if _, err := db.GetEngine(ctx).Table("project").Where("repo_id = ? ", repoID).Delete(&Project{}); err != nil {
+ return err
+ }
+ }
+
+ return updateRepositoryProjectCount(ctx, repoID)
+}
diff --git a/models/project/project_test.go b/models/project/project_test.go
new file mode 100644
index 0000000..8c660b9
--- /dev/null
+++ b/models/project/project_test.go
@@ -0,0 +1,124 @@
+// Copyright 2020 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"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsProjectTypeValid(t *testing.T) {
+ const UnknownType Type = 15
+
+ cases := []struct {
+ typ Type
+ valid bool
+ }{
+ {TypeIndividual, true},
+ {TypeRepository, true},
+ {TypeOrganization, true},
+ {UnknownType, false},
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.valid, IsTypeValid(v.typ))
+ }
+}
+
+func TestGetProjects(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ projects, err := db.Find[Project](db.DefaultContext, SearchOptions{RepoID: 1})
+ require.NoError(t, err)
+
+ // 1 value for this repo exists in the fixtures
+ assert.Len(t, projects, 1)
+
+ projects, err = db.Find[Project](db.DefaultContext, SearchOptions{RepoID: 3})
+ require.NoError(t, err)
+
+ // 1 value for this repo exists in the fixtures
+ assert.Len(t, projects, 1)
+}
+
+func TestProject(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ project := &Project{
+ Type: TypeRepository,
+ TemplateType: TemplateTypeBasicKanban,
+ CardType: CardTypeTextOnly,
+ Title: "New Project",
+ RepoID: 1,
+ CreatedUnix: timeutil.TimeStampNow(),
+ CreatorID: 2,
+ }
+
+ require.NoError(t, NewProject(db.DefaultContext, project))
+
+ _, err := GetProjectByID(db.DefaultContext, project.ID)
+ require.NoError(t, err)
+
+ // Update project
+ project.Title = "Updated title"
+ require.NoError(t, UpdateProject(db.DefaultContext, project))
+
+ projectFromDB, err := GetProjectByID(db.DefaultContext, project.ID)
+ require.NoError(t, err)
+
+ assert.Equal(t, project.Title, projectFromDB.Title)
+
+ require.NoError(t, ChangeProjectStatus(db.DefaultContext, project, true))
+
+ // Retrieve from DB afresh to check if it is truly closed
+ projectFromDB, err = GetProjectByID(db.DefaultContext, project.ID)
+ require.NoError(t, err)
+
+ assert.True(t, projectFromDB.IsClosed)
+}
+
+func TestProjectsSort(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ tests := []struct {
+ sortType string
+ wants []int64
+ }{
+ {
+ sortType: "default",
+ wants: []int64{1, 3, 2, 6, 5, 4},
+ },
+ {
+ sortType: "oldest",
+ wants: []int64{4, 5, 6, 2, 3, 1},
+ },
+ {
+ sortType: "recentupdate",
+ wants: []int64{1, 3, 2, 6, 5, 4},
+ },
+ {
+ sortType: "leastupdate",
+ wants: []int64{4, 5, 6, 2, 3, 1},
+ },
+ }
+
+ for _, tt := range tests {
+ projects, count, err := db.FindAndCount[Project](db.DefaultContext, SearchOptions{
+ OrderBy: GetSearchOrderByBySortType(tt.sortType),
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, int64(6), count)
+ if assert.Len(t, projects, 6) {
+ for i := range projects {
+ assert.EqualValues(t, tt.wants[i], projects[i].ID)
+ }
+ }
+ }
+}
diff --git a/models/project/template.go b/models/project/template.go
new file mode 100644
index 0000000..06d5d2a
--- /dev/null
+++ b/models/project/template.go
@@ -0,0 +1,45 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+type (
+ // TemplateType is used to represent a project template type
+ TemplateType uint8
+
+ // TemplateConfig is used to identify the template type of project that is being created
+ TemplateConfig struct {
+ TemplateType TemplateType
+ Translation string
+ }
+)
+
+const (
+ // TemplateTypeNone is a project template type that has no predefined columns
+ TemplateTypeNone TemplateType = iota
+
+ // TemplateTypeBasicKanban is a project template type that has basic predefined columns
+ TemplateTypeBasicKanban
+
+ // TemplateTypeBugTriage is a project template type that has predefined columns suited to hunting down bugs
+ TemplateTypeBugTriage
+)
+
+// GetTemplateConfigs retrieves the template configs of configurations project columns could have
+func GetTemplateConfigs() []TemplateConfig {
+ return []TemplateConfig{
+ {TemplateTypeNone, "repo.projects.type.none"},
+ {TemplateTypeBasicKanban, "repo.projects.type.basic_kanban"},
+ {TemplateTypeBugTriage, "repo.projects.type.bug_triage"},
+ }
+}
+
+// IsTemplateTypeValid checks if the project template type is valid
+func IsTemplateTypeValid(p TemplateType) bool {
+ switch p {
+ case TemplateTypeNone, TemplateTypeBasicKanban, TemplateTypeBugTriage:
+ return true
+ default:
+ return false
+ }
+}