初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+380
View File
@@ -0,0 +1,380 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"fmt"
"regexp"
"gitea.dev/models/db"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/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"`
NumIssues int64 `xorm:"-"`
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
}
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 errors.New("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 errors.New("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.MustDefaultColumn(ctx)
if err != nil {
return err
}
if err = moveIssuesToAnotherColumn(ctx, column, 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
}
func GetColumnByIDAndProjectID(ctx context.Context, columnID, projectID int64) (*Column, error) {
column := new(Column)
has, err := db.GetEngine(ctx).ID(columnID).And("project_id=?", projectID).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
}
// 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)
if err != nil {
return nil, err
}
if has {
return &column, nil
}
// 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
}
if has {
column.Default = true
if _, err := db.GetEngine(ctx).ID(column.ID).Cols("`default`").Update(&column); err != nil {
return nil, err
}
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{
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
})
}
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
}
// 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
})
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"fmt"
"testing"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestGetDefaultColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
projectWithoutDefault, err := GetProjectByID(t.Context(), 5)
assert.NoError(t, err)
// check if default column was added
column, err := projectWithoutDefault.MustDefaultColumn(t.Context())
assert.NoError(t, err)
assert.Equal(t, int64(5), column.ProjectID)
assert.Equal(t, "Done", column.Title)
projectWithMultipleDefaults, err := GetProjectByID(t.Context(), 6)
assert.NoError(t, err)
// check if multiple defaults were removed
column, err = projectWithMultipleDefaults.MustDefaultColumn(t.Context())
assert.NoError(t, err)
assert.Equal(t, int64(6), column.ProjectID)
assert.Equal(t, int64(9), column.ID) // there are 2 default columns in the test data, use the latest one
// set 8 as default column
assert.NoError(t, SetDefaultColumn(t.Context(), column.ProjectID, 8))
// then 9 will become a non-default column
column, err = GetColumn(t.Context(), 9)
assert.NoError(t, err)
assert.Equal(t, int64(6), column.ProjectID)
assert.False(t, column.Default)
}
func Test_moveIssuesToAnotherColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
column1 := unittest.AssertExistsAndLoadBean(t, &Column{ID: 1, ProjectID: 1})
issues, err := column1.GetIssues(t.Context())
assert.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(t.Context())
assert.NoError(t, err)
assert.Len(t, issues, 1)
assert.EqualValues(t, 3, issues[0].ID)
err = moveIssuesToAnotherColumn(t.Context(), column1, column2)
assert.NoError(t, err)
issues, err = column1.GetIssues(t.Context())
assert.NoError(t, err)
assert.Empty(t, issues)
issues, err = column2.GetIssues(t.Context())
assert.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) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
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
assert.EqualValues(t, 0, columns[1].Sorting)
assert.EqualValues(t, 0, columns[2].Sorting)
err = MoveColumnsOnProject(t.Context(), project1, map[int64]int64{
0: columns[1].ID,
1: columns[2].ID,
2: columns[0].ID,
})
assert.NoError(t, err)
columnsAfter, err := project1.GetColumns(t.Context())
assert.NoError(t, err)
assert.Len(t, columnsAfter, 3)
assert.Equal(t, columns[1].ID, columnsAfter[0].ID)
assert.Equal(t, columns[2].ID, columnsAfter[1].ID)
assert.Equal(t, columns[0].ID, columnsAfter[2].ID)
}
func Test_NewColumn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project1 := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
columns, err := project1.GetColumns(t.Context())
assert.NoError(t, err)
assert.Len(t, columns, 3)
for i := range maxProjectColumns - 3 {
err := NewColumn(t.Context(), &Column{
Title: fmt.Sprintf("column-%d", i+4),
ProjectID: project1.ID,
})
assert.NoError(t, err)
}
err = NewColumn(t.Context(), &Column{
Title: "column-21",
ProjectID: project1.ID,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "maximum number of columns reached")
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"errors"
"gitea.dev/models/db"
"gitea.dev/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
}
// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
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=?", projectID).
And("project_board_id=?", columnID).
Get(&res); err != nil {
return 0, err
}
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
}
func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error {
if oldColumn.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")
}
if oldColumn.ID == newColumn.ID {
return nil
}
movedIssues, err := oldColumn.GetIssues(ctx)
if err != nil {
return err
}
if len(movedIssues) == 0 {
return nil
}
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID)
if err != nil {
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
for i, issue := range movedIssues {
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
})
}
// DeleteAllProjectIssueByIssueIDsAndProjectIDs delete all project's issues by issue's and project's ids
func DeleteAllProjectIssueByIssueIDsAndProjectIDs(ctx context.Context, issueIDs, projectIDs []int64) error {
_, err := db.GetEngine(ctx).In("project_id", projectIDs).In("issue_id", issueIDs).Delete(&ProjectIssue{})
return err
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"gitea.dev/models/unittest"
_ "gitea.dev/models/repo"
)
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{
FixtureFiles: []string{
"project.yml",
"project_board.yml",
"project_issue.yml",
"repository.yml",
},
})
}
+481
View File
@@ -0,0 +1,481 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"fmt"
"html/template"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/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:"-"`
NumOpenIssues int64 `xorm:"-"`
NumClosedIssues int64 `xorm:"-"`
NumIssues int64 `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
}
func ProjectLinkForOrg(org *user_model.User, projectID int64) string { //nolint:revive // export stutter
return fmt.Sprintf("%s/-/projects/%d", org.HomeLink(), projectID)
}
func ProjectLinkForRepo(repo *repo_model.Repository, projectID int64) string { //nolint:revive // export stutter
return fmt.Sprintf("%s/projects/%d", repo.Link(), projectID)
}
// 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 ProjectLinkForOrg(p.Owner, p.ID)
}
if p.RepoID > 0 {
err := p.LoadRepo(ctx)
if err != nil {
log.Error("LoadRepo: %v", err)
return ""
}
return ProjectLinkForRepo(p.Repo, 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
case "alphabetically":
return "title ASC"
case "reversealphabetically":
return "title DESC"
default:
return db.SearchOrderByNewest
}
}
// NewProject creates a new Project
// The title will be cut off at 255 characters if it's longer than 255 characters.
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")
}
p.Title = util.EllipsisDisplayString(p.Title, 255)
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
}
// GetProjectsMapByIDs returns projects by a list of IDs.
func GetProjectsMapByIDs(ctx context.Context, ids []int64) (map[int64]*Project, error) {
projects := make(map[int64]*Project, len(ids))
if len(ids) == 0 {
return projects, nil
}
return projects, db.GetEngine(ctx).In("id", ids).Find(&projects)
}
func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) {
p := new(Project)
has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).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
}
// GetAllProjectsIDsByOwnerID returns the all projects ids it owns
func GetAllProjectsIDsByOwnerIDAndType(ctx context.Context, ownerID int64, projectType Type) ([]int64, error) {
projects := make([]int64, 0)
return projects, db.GetEngine(ctx).Table(&Project{}).Where("owner_id=? AND type=?", ownerID, projectType).Cols("id").Find(&projects)
}
// UpdateProject updates project properties
func UpdateProject(ctx context.Context, p *Project) error {
if !IsCardTypeValid(p.CardType) {
p.CardType = CardTypeTextOnly
}
p.Title = util.EllipsisDisplayString(p.Title, 255)
_, 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 {
return db.WithTx(ctx, func(ctx context.Context) error {
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}
}
return changeProjectStatus(ctx, p, isClosed)
})
}
// ChangeProjectStatus toggle a project between opened and closed
func ChangeProjectStatus(ctx context.Context, p *Project, isClosed bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
return changeProjectStatus(ctx, p, isClosed)
})
}
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)
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
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) {
assert.NoError(t, unittest.PrepareTestDatabase())
projects, err := db.Find[Project](t.Context(), SearchOptions{RepoID: 1})
assert.NoError(t, err)
// 1 value for this repo exists in the fixtures
assert.Len(t, projects, 1)
projects, err = db.Find[Project](t.Context(), SearchOptions{RepoID: 3})
assert.NoError(t, err)
// 1 value for this repo exists in the fixtures
assert.Len(t, projects, 1)
}
func TestProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
project := &Project{
Type: TypeRepository,
TemplateType: TemplateTypeBasicKanban,
CardType: CardTypeTextOnly,
Title: "New Project",
RepoID: 1,
CreatedUnix: timeutil.TimeStampNow(),
CreatorID: 2,
}
assert.NoError(t, NewProject(t.Context(), project))
_, err := GetProjectByID(t.Context(), project.ID)
assert.NoError(t, err)
// Update project
project.Title = "Updated title"
assert.NoError(t, UpdateProject(t.Context(), project))
projectFromDB, err := GetProjectByID(t.Context(), project.ID)
assert.NoError(t, err)
assert.Equal(t, project.Title, projectFromDB.Title)
assert.NoError(t, ChangeProjectStatus(t.Context(), project, true))
// Retrieve from DB afresh to check if it is truly closed
projectFromDB, err = GetProjectByID(t.Context(), project.ID)
assert.NoError(t, err)
assert.True(t, projectFromDB.IsClosed)
}
func TestProjectsSort(t *testing.T) {
assert.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](t.Context(), SearchOptions{
OrderBy: GetSearchOrderByBySortType(tt.sortType),
})
assert.NoError(t, err)
assert.Equal(t, int64(6), count)
if assert.Len(t, projects, 6) {
for i := range projects {
assert.Equal(t, tt.wants[i], projects[i].ID)
}
}
}
}
+45
View File
@@ -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
}
}