diff options
Diffstat (limited to '')
-rw-r--r-- | models/project/column.go | 359 | ||||
-rw-r--r-- | models/project/column_test.go | 128 | ||||
-rw-r--r-- | models/project/issue.go | 143 | ||||
-rw-r--r-- | models/project/main_test.go | 23 | ||||
-rw-r--r-- | models/project/project.go | 451 | ||||
-rw-r--r-- | models/project/project_test.go | 124 | ||||
-rw-r--r-- | models/project/template.go | 45 |
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 + } +} |