aboutsummaryrefslogtreecommitdiffstats
path: root/models
diff options
context:
space:
mode:
authorLanre Adelowo <yo@lanre.wtf>2020-08-17 04:07:38 +0100
committerGitHub <noreply@github.com>2020-08-16 23:07:38 -0400
commit4027c5dd7c11c1256094e202be591ec1b86a011e (patch)
tree49e361a50395f0496c49d52bfd571ee47c1ebf44 /models
parentd285b5d35a44bf9fde0682532aeef9550f78cf83 (diff)
downloadgitea-4027c5dd7c11c1256094e202be591ec1b86a011e.tar.gz
gitea-4027c5dd7c11c1256094e202be591ec1b86a011e.zip
Kanban board (#8346)
Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: jaqra <48099350+jaqra@users.noreply.github.com> Co-authored-by: Kerry <flatline-studios@users.noreply.github.com> Co-authored-by: Jaqra <jaqra@hotmail.com> Co-authored-by: Kyle Evans <kevans91@users.noreply.github.com> Co-authored-by: Tsakiridis Ilias <TsakiDev@users.noreply.github.com> Co-authored-by: Ilias Tsakiridis <ilias.tsakiridis@outlook.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: techknowlogick <techknowlogick@gitea.io>
Diffstat (limited to 'models')
-rw-r--r--models/error.go38
-rw-r--r--models/fixtures/project.yml26
-rw-r--r--models/fixtures/project_board.yml23
-rw-r--r--models/fixtures/project_issue.yml23
-rw-r--r--models/fixtures/repo_unit.yml18
-rw-r--r--models/fixtures/repository.yml6
-rw-r--r--models/issue.go25
-rw-r--r--models/issue_comment.go38
-rw-r--r--models/issue_milestone.go46
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v146.go85
-rw-r--r--models/models.go4
-rw-r--r--models/project.go307
-rw-r--r--models/project_board.go220
-rw-r--r--models/project_issue.go210
-rw-r--r--models/project_test.go82
-rw-r--r--models/repo.go23
-rw-r--r--models/repo_unit.go2
-rw-r--r--models/unit.go14
19 files changed, 1185 insertions, 7 deletions
diff --git a/models/error.go b/models/error.go
index e9343cbe7c..13391e5d87 100644
--- a/models/error.go
+++ b/models/error.go
@@ -1586,6 +1586,44 @@ func (err ErrLabelNotExist) Error() string {
return fmt.Sprintf("label does not exist [label_id: %d]", err.LabelID)
}
+// __________ __ __
+// \______ \_______ ____ |__| ____ _____/ |_ ______
+// | ___/\_ __ \/ _ \ | |/ __ \_/ ___\ __\/ ___/
+// | | | | \( <_> ) | \ ___/\ \___| | \___ \
+// |____| |__| \____/\__| |\___ >\___ >__| /____ >
+// \______| \/ \/ \/
+
+// 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)
+}
+
+// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrProjectBoardNotExist struct {
+ BoardID int64
+}
+
+// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist
+func IsErrProjectBoardNotExist(err error) bool {
+ _, ok := err.(ErrProjectBoardNotExist)
+ return ok
+}
+
+func (err ErrProjectBoardNotExist) Error() string {
+ return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID)
+}
+
// _____ .__.__ __
// / \ |__| | ____ _______/ |_ ____ ____ ____
// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \
diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml
new file mode 100644
index 0000000000..3d42597c5e
--- /dev/null
+++ b/models/fixtures/project.yml
@@ -0,0 +1,26 @@
+-
+ id: 1
+ title: First project
+ repo_id: 1
+ is_closed: false
+ creator_id: 2
+ board_type: 1
+ type: 2
+
+-
+ id: 2
+ title: second project
+ repo_id: 3
+ is_closed: false
+ creator_id: 3
+ board_type: 1
+ type: 2
+
+-
+ id: 3
+ title: project on repo with disabled project
+ repo_id: 4
+ is_closed: true
+ creator_id: 5
+ board_type: 1
+ type: 2
diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml
new file mode 100644
index 0000000000..9e06e8c239
--- /dev/null
+++ b/models/fixtures/project_board.yml
@@ -0,0 +1,23 @@
+-
+ id: 1
+ project_id: 1
+ title: To Do
+ creator_id: 2
+ created_unix: 1588117528
+ updated_unix: 1588117528
+
+-
+ id: 2
+ project_id: 1
+ title: In Progress
+ creator_id: 2
+ created_unix: 1588117528
+ updated_unix: 1588117528
+
+-
+ id: 3
+ project_id: 1
+ title: Done
+ creator_id: 2
+ created_unix: 1588117528
+ updated_unix: 1588117528
diff --git a/models/fixtures/project_issue.yml b/models/fixtures/project_issue.yml
new file mode 100644
index 0000000000..b1af05908a
--- /dev/null
+++ b/models/fixtures/project_issue.yml
@@ -0,0 +1,23 @@
+-
+ id: 1
+ issue_id: 1
+ project_id: 1
+ project_board_id: 1
+
+-
+ id: 2
+ issue_id: 2
+ project_id: 1
+ project_board_id: 0 # no board assigned
+
+-
+ id: 3
+ issue_id: 3
+ project_id: 1
+ project_board_id: 2
+
+-
+ id: 4
+ issue_id: 5
+ project_id: 1
+ project_board_id: 3
diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml
index 35b9b92b79..726abf9af9 100644
--- a/models/fixtures/repo_unit.yml
+++ b/models/fixtures/repo_unit.yml
@@ -514,3 +514,21 @@
type: 3
config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}"
created_unix: 946684810
+
+-
+ id: 75
+ repo_id: 1
+ type: 8
+ created_unix: 946684810
+
+-
+ id: 76
+ repo_id: 2
+ type: 8
+ created_unix: 946684810
+
+-
+ id: 77
+ repo_id: 3
+ type: 8
+ created_unix: 946684810
diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml
index 3b86dd0f81..a44e480270 100644
--- a/models/fixtures/repository.yml
+++ b/models/fixtures/repository.yml
@@ -13,6 +13,8 @@
num_milestones: 3
num_closed_milestones: 1
num_watches: 4
+ num_projects: 1
+ num_closed_projects: 0
status: 0
-
@@ -42,6 +44,8 @@
num_pulls: 0
num_closed_pulls: 0
num_watches: 0
+ num_projects: 1
+ num_closed_projects: 0
status: 0
-
@@ -56,6 +60,8 @@
num_pulls: 0
num_closed_pulls: 0
num_stars: 1
+ num_projects: 0
+ num_closed_projects: 1
status: 0
-
diff --git a/models/issue.go b/models/issue.go
index 1a4de26b3a..07d7fc9956 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -41,6 +41,7 @@ type Issue struct {
Labels []*Label `xorm:"-"`
MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"`
+ Project *Project `xorm:"-"`
Priority int
AssigneeID int64 `xorm:"-"`
Assignee *User `xorm:"-"`
@@ -274,6 +275,10 @@ func (issue *Issue) loadAttributes(e Engine) (err error) {
return
}
+ if err = issue.loadProject(e); err != nil {
+ return
+ }
+
if err = issue.loadAssignees(e); err != nil {
return
}
@@ -1062,6 +1067,8 @@ type IssuesOptions struct {
PosterID int64
MentionedID int64
MilestoneIDs []int64
+ ProjectID int64
+ ProjectBoardID int64
IsClosed util.OptionalBool
IsPull util.OptionalBool
LabelIDs []int64
@@ -1147,6 +1154,19 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) {
sess.In("issue.milestone_id", opts.MilestoneIDs)
}
+ if opts.ProjectID > 0 {
+ sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
+ And("project_issue.project_id=?", opts.ProjectID)
+ }
+
+ if opts.ProjectBoardID != 0 {
+ if opts.ProjectBoardID > 0 {
+ sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID}))
+ } else {
+ sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
+ }
+ }
+
switch opts.IsPull {
case util.OptionalBoolTrue:
sess.And("issue.is_pull=?", true)
@@ -1953,6 +1973,11 @@ func deleteIssuesByRepoID(sess Engine, repoID int64) (attachmentPaths []string,
return
}
+ if _, err = sess.In("issue_id", deleteCond).
+ Delete(&ProjectIssue{}); err != nil {
+ return
+ }
+
var attachments []*Attachment
if err = sess.In("issue_id", deleteCond).
Find(&attachments); err != nil {
diff --git a/models/issue_comment.go b/models/issue_comment.go
index 94fca493e0..726ed7472b 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -97,6 +97,10 @@ const (
CommentTypeMergePull
// push to PR head branch
CommentTypePullPush
+ // Project changed
+ CommentTypeProject
+ // Project board changed
+ CommentTypeProjectBoard
)
// CommentTag defines comment tag type
@@ -122,6 +126,10 @@ type Comment struct {
Issue *Issue `xorm:"-"`
LabelID int64
Label *Label `xorm:"-"`
+ OldProjectID int64
+ ProjectID int64
+ OldProject *Project `xorm:"-"`
+ Project *Project `xorm:"-"`
OldMilestoneID int64
MilestoneID int64
OldMilestone *Milestone `xorm:"-"`
@@ -389,6 +397,32 @@ func (c *Comment) LoadLabel() error {
return nil
}
+// LoadProject if comment.Type is CommentTypeProject, then load project.
+func (c *Comment) LoadProject() error {
+
+ if c.OldProjectID > 0 {
+ var oldProject Project
+ has, err := x.ID(c.OldProjectID).Get(&oldProject)
+ if err != nil {
+ return err
+ } else if has {
+ c.OldProject = &oldProject
+ }
+ }
+
+ if c.ProjectID > 0 {
+ var project Project
+ has, err := x.ID(c.ProjectID).Get(&project)
+ if err != nil {
+ return err
+ } else if has {
+ c.Project = &project
+ }
+ }
+
+ return nil
+}
+
// LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone
func (c *Comment) LoadMilestone() error {
if c.OldMilestoneID > 0 {
@@ -647,6 +681,8 @@ func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err
LabelID: LabelID,
OldMilestoneID: opts.OldMilestoneID,
MilestoneID: opts.MilestoneID,
+ OldProjectID: opts.OldProjectID,
+ ProjectID: opts.ProjectID,
RemovedAssignee: opts.RemovedAssignee,
AssigneeID: opts.AssigneeID,
CommitID: opts.CommitID,
@@ -810,6 +846,8 @@ type CreateCommentOptions struct {
DependentIssueID int64
OldMilestoneID int64
MilestoneID int64
+ OldProjectID int64
+ ProjectID int64
AssigneeID int64
RemovedAssignee bool
OldTitle string
diff --git a/models/issue_milestone.go b/models/issue_milestone.go
index 824b939a56..f4fba84ec0 100644
--- a/models/issue_milestone.go
+++ b/models/issue_milestone.go
@@ -183,6 +183,33 @@ func updateMilestoneCompleteness(e Engine, milestoneID int64) error {
return err
}
+// ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo.
+func ChangeMilestoneStatusByRepoIDAndID(repoID, milestoneID int64, isClosed bool) error {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ m := &Milestone{
+ ID: milestoneID,
+ RepoID: repoID,
+ }
+
+ has, err := sess.ID(milestoneID).Where("repo_id = ?", repoID).Get(m)
+ if err != nil {
+ return err
+ } else if !has {
+ return ErrMilestoneNotExist{ID: milestoneID, RepoID: repoID}
+ }
+
+ if err := changeMilestoneStatus(sess, m, isClosed); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
// ChangeMilestoneStatus changes the milestone open/closed status.
func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) {
sess := x.NewSession()
@@ -191,20 +218,27 @@ func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) {
return err
}
+ if err := changeMilestoneStatus(sess, m, isClosed); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func changeMilestoneStatus(e Engine, m *Milestone, isClosed bool) error {
m.IsClosed = isClosed
if isClosed {
m.ClosedDateUnix = timeutil.TimeStampNow()
}
- if _, err := sess.ID(m.ID).Cols("is_closed", "closed_date_unix").Update(m); err != nil {
+ count, err := e.ID(m.ID).Where("repo_id = ? AND is_closed = ?", m.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(m)
+ if err != nil {
return err
}
-
- if err := updateRepoMilestoneNum(sess, m.RepoID); err != nil {
- return err
+ if count < 1 {
+ return nil
}
-
- return sess.Commit()
+ return updateRepoMilestoneNum(e, m.RepoID)
}
func changeMilestoneAssign(e *xorm.Session, doer *User, issue *Issue, oldMilestoneID int64) error {
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 834ac3bd68..b9fdfbfe7e 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -224,6 +224,8 @@ var migrations = []Migration{
NewMigration("update Matrix Webhook http method to 'PUT'", updateMatrixWebhookHTTPMethod),
// v145 -> v146
NewMigration("Increase Language field to 50 in LanguageStats", increaseLanguageField),
+ // v146 -> v147
+ NewMigration("Add projects info to repository table", addProjectsInfo),
}
// GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v146.go b/models/migrations/v146.go
new file mode 100644
index 0000000000..847bcf567c
--- /dev/null
+++ b/models/migrations/v146.go
@@ -0,0 +1,85 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+func addProjectsInfo(x *xorm.Engine) error {
+
+ // Create new tables
+ type (
+ ProjectType uint8
+ ProjectBoardType uint8
+ )
+
+ type Project struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string `xorm:"INDEX NOT NULL"`
+ Description string `xorm:"TEXT"`
+ RepoID int64 `xorm:"INDEX"`
+ CreatorID int64 `xorm:"NOT NULL"`
+ IsClosed bool `xorm:"INDEX"`
+
+ BoardType ProjectBoardType
+ Type ProjectType
+
+ ClosedDateUnix timeutil.TimeStamp
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+ }
+
+ if err := x.Sync2(new(Project)); err != nil {
+ return err
+ }
+
+ type Comment struct {
+ OldProjectID int64
+ ProjectID int64
+ }
+
+ if err := x.Sync2(new(Comment)); err != nil {
+ return err
+ }
+
+ type Repository struct {
+ ID int64
+ NumProjects int `xorm:"NOT NULL DEFAULT 0"`
+ NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"`
+ }
+
+ if err := x.Sync2(new(Repository)); err != nil {
+ return err
+ }
+
+ // ProjectIssue saves relation from issue to a project
+ type ProjectIssue struct {
+ ID int64 `xorm:"pk autoincr"`
+ IssueID int64 `xorm:"INDEX"`
+ ProjectID int64 `xorm:"INDEX"`
+ ProjectBoardID int64 `xorm:"INDEX"`
+ }
+
+ if err := x.Sync2(new(ProjectIssue)); err != nil {
+ return err
+ }
+
+ type ProjectBoard struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string
+ Default bool `xorm:"NOT NULL DEFAULT false"`
+
+ ProjectID int64 `xorm:"INDEX NOT NULL"`
+ CreatorID int64 `xorm:"NOT NULL"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+ }
+
+ return x.Sync2(new(ProjectBoard))
+}
diff --git a/models/models.go b/models/models.go
index d0703be300..e0dd3ed2a4 100644
--- a/models/models.go
+++ b/models/models.go
@@ -45,6 +45,7 @@ type Engine interface {
SQL(interface{}, ...interface{}) *xorm.Session
Where(interface{}, ...interface{}) *xorm.Session
Asc(colNames ...string) *xorm.Session
+ Desc(colNames ...string) *xorm.Session
Limit(limit int, start ...int) *xorm.Session
SumInt(bean interface{}, columnName string) (res int64, err error)
}
@@ -125,6 +126,9 @@ func init() {
new(Task),
new(LanguageStat),
new(EmailHash),
+ new(Project),
+ new(ProjectBoard),
+ new(ProjectIssue),
)
gonicNames := []string{"SSL", "UID"}
diff --git a/models/project.go b/models/project.go
new file mode 100644
index 0000000000..e032da351d
--- /dev/null
+++ b/models/project.go
@@ -0,0 +1,307 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+ "errors"
+ "fmt"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+type (
+ // ProjectsConfig is used to identify the type of board that is being created
+ ProjectsConfig struct {
+ BoardType ProjectBoardType
+ Translation string
+ }
+
+ // ProjectType is used to identify the type of project in question and ownership
+ ProjectType uint8
+)
+
+const (
+ // ProjectTypeIndividual is a type of project board that is owned by an individual
+ ProjectTypeIndividual ProjectType = iota + 1
+
+ // ProjectTypeRepository is a project that is tied to a repository
+ ProjectTypeRepository
+
+ // ProjectTypeOrganization is a project that is tied to an organisation
+ ProjectTypeOrganization
+)
+
+// Project represents a project board
+type Project struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string `xorm:"INDEX NOT NULL"`
+ Description string `xorm:"TEXT"`
+ RepoID int64 `xorm:"INDEX"`
+ CreatorID int64 `xorm:"NOT NULL"`
+ IsClosed bool `xorm:"INDEX"`
+ BoardType ProjectBoardType
+ Type ProjectType
+
+ RenderedContent string `xorm:"-"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+ ClosedDateUnix timeutil.TimeStamp
+}
+
+// GetProjectsConfig retrieves the types of configurations projects could have
+func GetProjectsConfig() []ProjectsConfig {
+ return []ProjectsConfig{
+ {ProjectBoardTypeNone, "repo.projects.type.none"},
+ {ProjectBoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
+ {ProjectBoardTypeBugTriage, "repo.projects.type.bug_triage"},
+ }
+}
+
+// IsProjectTypeValid checks if a project type is valid
+func IsProjectTypeValid(p ProjectType) bool {
+ switch p {
+ case ProjectTypeRepository:
+ return true
+ default:
+ return false
+ }
+}
+
+// ProjectSearchOptions are options for GetProjects
+type ProjectSearchOptions struct {
+ RepoID int64
+ Page int
+ IsClosed util.OptionalBool
+ SortType string
+ Type ProjectType
+}
+
+// GetProjects returns a list of all projects that have been created in the repository
+func GetProjects(opts ProjectSearchOptions) ([]*Project, int64, error) {
+ return getProjects(x, opts)
+}
+
+func getProjects(e Engine, opts ProjectSearchOptions) ([]*Project, int64, error) {
+
+ projects := make([]*Project, 0, setting.UI.IssuePagingNum)
+
+ var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID}
+ switch opts.IsClosed {
+ case util.OptionalBoolTrue:
+ cond = cond.And(builder.Eq{"is_closed": true})
+ case util.OptionalBoolFalse:
+ cond = cond.And(builder.Eq{"is_closed": false})
+ }
+
+ if opts.Type > 0 {
+ cond = cond.And(builder.Eq{"type": opts.Type})
+ }
+
+ count, err := e.Where(cond).Count(new(Project))
+ if err != nil {
+ return nil, 0, fmt.Errorf("Count: %v", err)
+ }
+
+ e = e.Where(cond)
+
+ if opts.Page > 0 {
+ e = e.Limit(setting.UI.IssuePagingNum, (opts.Page-1)*setting.UI.IssuePagingNum)
+ }
+
+ switch opts.SortType {
+ case "oldest":
+ e.Desc("created_unix")
+ case "recentupdate":
+ e.Desc("updated_unix")
+ case "leastupdate":
+ e.Asc("updated_unix")
+ default:
+ e.Asc("created_unix")
+ }
+
+ return projects, count, e.Find(&projects)
+}
+
+// NewProject creates a new Project
+func NewProject(p *Project) error {
+ if !IsProjectBoardTypeValid(p.BoardType) {
+ p.BoardType = ProjectBoardTypeNone
+ }
+
+ if !IsProjectTypeValid(p.Type) {
+ return errors.New("project type is not valid")
+ }
+
+ sess := x.NewSession()
+ defer sess.Close()
+
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ if _, err := sess.Insert(p); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
+ return err
+ }
+
+ if err := createBoardsForProjectsType(sess, p); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+// GetProjectByID returns the projects in a repository
+func GetProjectByID(id int64) (*Project, error) {
+ return getProjectByID(x, id)
+}
+
+func getProjectByID(e Engine, id int64) (*Project, error) {
+ p := new(Project)
+
+ has, err := e.ID(id).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(p *Project) error {
+ return updateProject(x, p)
+}
+
+func updateProject(e Engine, p *Project) error {
+ _, err := e.ID(p.ID).Cols(
+ "title",
+ "description",
+ ).Update(p)
+ return err
+}
+
+func updateRepositoryProjectCount(e Engine, repoID int64) error {
+ if _, err := e.Exec(builder.Update(
+ builder.Eq{
+ "`num_projects`": builder.Select("count(*)").From("`project`").
+ Where(builder.Eq{"`project`.`repo_id`": repoID}.
+ And(builder.Eq{"`project`.`type`": ProjectTypeRepository})),
+ }).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil {
+ return err
+ }
+
+ if _, err := e.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`": ProjectTypeRepository}).
+ 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(repoID, projectID int64, isClosed bool) error {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ p := new(Project)
+
+ has, err := sess.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(sess, p, isClosed); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+// ChangeProjectStatus toggle a project between opened and closed
+func ChangeProjectStatus(p *Project, isClosed bool) error {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ if err := changeProjectStatus(sess, p, isClosed); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func changeProjectStatus(e Engine, p *Project, isClosed bool) error {
+ p.IsClosed = isClosed
+ p.ClosedDateUnix = timeutil.TimeStampNow()
+ count, err := e.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(e, p.RepoID)
+}
+
+// DeleteProjectByID deletes a project from a repository.
+func DeleteProjectByID(id int64) error {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ if err := deleteProjectByID(sess, id); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func deleteProjectByID(e Engine, id int64) error {
+ p, err := getProjectByID(e, id)
+ if err != nil {
+ if IsErrProjectNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ if err := deleteProjectIssuesByProjectID(e, id); err != nil {
+ return err
+ }
+
+ if err := deleteProjectBoardByProjectID(e, id); err != nil {
+ return err
+ }
+
+ if _, err = e.ID(p.ID).Delete(new(Project)); err != nil {
+ return err
+ }
+
+ return updateRepositoryProjectCount(e, p.RepoID)
+}
diff --git a/models/project_board.go b/models/project_board.go
new file mode 100644
index 0000000000..260fc8304b
--- /dev/null
+++ b/models/project_board.go
@@ -0,0 +1,220 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+type (
+ // ProjectBoardType is used to represent a project board type
+ ProjectBoardType uint8
+
+ // ProjectBoardList is a list of all project boards in a repository
+ ProjectBoardList []*ProjectBoard
+)
+
+const (
+ // ProjectBoardTypeNone is a project board type that has no predefined columns
+ ProjectBoardTypeNone ProjectBoardType = iota
+
+ // ProjectBoardTypeBasicKanban is a project board type that has basic predefined columns
+ ProjectBoardTypeBasicKanban
+
+ // ProjectBoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
+ ProjectBoardTypeBugTriage
+)
+
+// ProjectBoard is used to represent boards on a project
+type ProjectBoard struct {
+ ID int64 `xorm:"pk autoincr"`
+ Title string
+ Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
+
+ ProjectID int64 `xorm:"INDEX NOT NULL"`
+ CreatorID int64 `xorm:"NOT NULL"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+
+ Issues []*Issue `xorm:"-"`
+}
+
+// IsProjectBoardTypeValid checks if the project board type is valid
+func IsProjectBoardTypeValid(p ProjectBoardType) bool {
+ switch p {
+ case ProjectBoardTypeNone, ProjectBoardTypeBasicKanban, ProjectBoardTypeBugTriage:
+ return true
+ default:
+ return false
+ }
+}
+
+func createBoardsForProjectsType(sess *xorm.Session, project *Project) error {
+
+ var items []string
+
+ switch project.BoardType {
+
+ case ProjectBoardTypeBugTriage:
+ items = setting.Project.ProjectBoardBugTriageType
+
+ case ProjectBoardTypeBasicKanban:
+ items = setting.Project.ProjectBoardBasicKanbanType
+
+ case ProjectBoardTypeNone:
+ fallthrough
+ default:
+ return nil
+ }
+
+ if len(items) == 0 {
+ return nil
+ }
+
+ var boards = make([]ProjectBoard, 0, len(items))
+
+ for _, v := range items {
+ boards = append(boards, ProjectBoard{
+ CreatedUnix: timeutil.TimeStampNow(),
+ CreatorID: project.CreatorID,
+ Title: v,
+ ProjectID: project.ID,
+ })
+ }
+
+ _, err := sess.Insert(boards)
+ return err
+}
+
+// NewProjectBoard adds a new project board to a given project
+func NewProjectBoard(board *ProjectBoard) error {
+ _, err := x.Insert(board)
+ return err
+}
+
+// DeleteProjectBoardByID removes all issues references to the project board.
+func DeleteProjectBoardByID(boardID int64) error {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ if err := deleteProjectBoardByID(sess, boardID); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func deleteProjectBoardByID(e Engine, boardID int64) error {
+ board, err := getProjectBoard(e, boardID)
+ if err != nil {
+ if IsErrProjectBoardNotExist(err) {
+ return nil
+ }
+
+ return err
+ }
+
+ if err = board.removeIssues(e); err != nil {
+ return err
+ }
+
+ if _, err := e.ID(board.ID).Delete(board); err != nil {
+ return err
+ }
+ return nil
+}
+
+func deleteProjectBoardByProjectID(e Engine, projectID int64) error {
+ _, err := e.Where("project_id=?", projectID).Delete(&ProjectBoard{})
+ return err
+}
+
+// GetProjectBoard fetches the current board of a project
+func GetProjectBoard(boardID int64) (*ProjectBoard, error) {
+ return getProjectBoard(x, boardID)
+}
+
+func getProjectBoard(e Engine, boardID int64) (*ProjectBoard, error) {
+ board := new(ProjectBoard)
+
+ has, err := e.ID(boardID).Get(board)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrProjectBoardNotExist{BoardID: boardID}
+ }
+
+ return board, nil
+}
+
+// UpdateProjectBoard updates the title of a project board
+func UpdateProjectBoard(board *ProjectBoard) error {
+ return updateProjectBoard(x, board)
+}
+
+func updateProjectBoard(e Engine, board *ProjectBoard) error {
+ _, err := e.ID(board.ID).Cols(
+ "title",
+ "default",
+ ).Update(board)
+ return err
+}
+
+// GetProjectBoards fetches all boards related to a project
+func GetProjectBoards(projectID int64) ([]*ProjectBoard, error) {
+
+ var boards = make([]*ProjectBoard, 0, 5)
+
+ sess := x.Where("project_id=?", projectID)
+ return boards, sess.Find(&boards)
+}
+
+// GetUncategorizedBoard represents a board for issues not assigned to one
+func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) {
+ return &ProjectBoard{
+ ProjectID: projectID,
+ Title: "Uncategorized",
+ Default: true,
+ }, nil
+}
+
+// LoadIssues load issues assigned to this board
+func (b *ProjectBoard) LoadIssues() (IssueList, error) {
+ var boardID int64
+ if !b.Default {
+ boardID = b.ID
+
+ } else {
+ // Issues without ProjectBoardID
+ boardID = -1
+ }
+ issues, err := Issues(&IssuesOptions{
+ ProjectBoardID: boardID,
+ ProjectID: b.ProjectID,
+ })
+ b.Issues = issues
+ return issues, err
+}
+
+// LoadIssues load issues assigned to the boards
+func (bs ProjectBoardList) LoadIssues() (IssueList, error) {
+ issues := make(IssueList, 0, len(bs)*10)
+ for i := range bs {
+ il, err := bs[i].LoadIssues()
+ if err != nil {
+ return nil, err
+ }
+ bs[i].Issues = il
+ issues = append(issues, il...)
+ }
+ return issues, nil
+}
diff --git a/models/project_issue.go b/models/project_issue.go
new file mode 100644
index 0000000000..c41bfe5158
--- /dev/null
+++ b/models/project_issue.go
@@ -0,0 +1,210 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+ "fmt"
+
+ "xorm.io/xorm"
+)
+
+// ProjectIssue saves relation from issue to a project
+type ProjectIssue struct {
+ ID int64 `xorm:"pk autoincr"`
+ IssueID int64 `xorm:"INDEX"`
+ ProjectID int64 `xorm:"INDEX"`
+
+ // If 0, then it has not been added to a specific board in the project
+ ProjectBoardID int64 `xorm:"INDEX"`
+}
+
+func deleteProjectIssuesByProjectID(e Engine, projectID int64) error {
+ _, err := e.Where("project_id=?", projectID).Delete(&ProjectIssue{})
+ return err
+}
+
+// ___
+// |_ _|___ ___ _ _ ___
+// | |/ __/ __| | | |/ _ \
+// | |\__ \__ \ |_| | __/
+// |___|___/___/\__,_|\___|
+
+// LoadProject load the project the issue was assigned to
+func (i *Issue) LoadProject() (err error) {
+ return i.loadProject(x)
+}
+
+func (i *Issue) loadProject(e Engine) (err error) {
+ if i.Project == nil {
+ var p Project
+ if _, err = e.Table("project").
+ Join("INNER", "project_issue", "project.id=project_issue.project_id").
+ Where("project_issue.issue_id = ?", i.ID).
+ Get(&p); err != nil {
+ return err
+ }
+ i.Project = &p
+ }
+ return
+}
+
+// ProjectID return project id if issue was assigned to one
+func (i *Issue) ProjectID() int64 {
+ return i.projectID(x)
+}
+
+func (i *Issue) projectID(e Engine) int64 {
+ var ip ProjectIssue
+ has, err := e.Where("issue_id=?", i.ID).Get(&ip)
+ if err != nil || !has {
+ return 0
+ }
+ return ip.ProjectID
+}
+
+// ProjectBoardID return project board id if issue was assigned to one
+func (i *Issue) ProjectBoardID() int64 {
+ return i.projectBoardID(x)
+}
+
+func (i *Issue) projectBoardID(e Engine) int64 {
+ var ip ProjectIssue
+ has, err := e.Where("issue_id=?", i.ID).Get(&ip)
+ if err != nil || !has {
+ return 0
+ }
+ return ip.ProjectBoardID
+}
+
+// ____ _ _
+// | _ \ _ __ ___ (_) ___ ___| |_
+// | |_) | '__/ _ \| |/ _ \/ __| __|
+// | __/| | | (_) | | __/ (__| |_
+// |_| |_| \___// |\___|\___|\__|
+// |__/
+
+// NumIssues return counter of all issues assigned to a project
+func (p *Project) NumIssues() int {
+ c, err := x.Table("project_issue").
+ Where("project_id=?", p.ID).
+ GroupBy("issue_id").
+ Cols("issue_id").
+ Count()
+ if err != nil {
+ return 0
+ }
+ return int(c)
+}
+
+// NumClosedIssues return counter of closed issues assigned to a project
+func (p *Project) NumClosedIssues() int {
+ c, err := x.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 {
+ return 0
+ }
+ return int(c)
+}
+
+// NumOpenIssues return counter of open issues assigned to a project
+func (p *Project) NumOpenIssues() int {
+ c, err := x.Table("project_issue").
+ Join("INNER", "issue", "project_issue.issue_id=issue.id").
+ Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).Count("issue.id")
+ if err != nil {
+ return 0
+ }
+ return int(c)
+}
+
+// ChangeProjectAssign changes the project associated with an issue
+func ChangeProjectAssign(issue *Issue, doer *User, newProjectID int64) error {
+
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ if err := addUpdateIssueProject(sess, issue, doer, newProjectID); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func addUpdateIssueProject(e *xorm.Session, issue *Issue, doer *User, newProjectID int64) error {
+
+ oldProjectID := issue.projectID(e)
+
+ if _, err := e.Where("project_issue.issue_id=?", issue.ID).Delete(&ProjectIssue{}); err != nil {
+ return err
+ }
+
+ if err := issue.loadRepo(e); err != nil {
+ return err
+ }
+
+ if oldProjectID > 0 || newProjectID > 0 {
+ if _, err := createComment(e, &CreateCommentOptions{
+ Type: CommentTypeProject,
+ Doer: doer,
+ Repo: issue.Repo,
+ Issue: issue,
+ OldProjectID: oldProjectID,
+ ProjectID: newProjectID,
+ }); err != nil {
+ return err
+ }
+ }
+
+ _, err := e.Insert(&ProjectIssue{
+ IssueID: issue.ID,
+ ProjectID: newProjectID,
+ })
+ return err
+}
+
+// ____ _ _ ____ _
+// | _ \ _ __ ___ (_) ___ ___| |_| __ ) ___ __ _ _ __ __| |
+// | |_) | '__/ _ \| |/ _ \/ __| __| _ \ / _ \ / _` | '__/ _` |
+// | __/| | | (_) | | __/ (__| |_| |_) | (_) | (_| | | | (_| |
+// |_| |_| \___// |\___|\___|\__|____/ \___/ \__,_|_| \__,_|
+// |__/
+
+// MoveIssueAcrossProjectBoards move a card from one board to another
+func MoveIssueAcrossProjectBoards(issue *Issue, board *ProjectBoard) error {
+
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return err
+ }
+
+ var pis ProjectIssue
+ has, err := sess.Where("issue_id=?", issue.ID).Get(&pis)
+ if err != nil {
+ return err
+ }
+
+ if !has {
+ return fmt.Errorf("issue has to be added to a project first")
+ }
+
+ pis.ProjectBoardID = board.ID
+ if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil {
+ return err
+ }
+
+ return sess.Commit()
+}
+
+func (pb *ProjectBoard) removeIssues(e Engine) error {
+ _, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", pb.ID)
+ return err
+}
diff --git a/models/project_test.go b/models/project_test.go
new file mode 100644
index 0000000000..49c46f9184
--- /dev/null
+++ b/models/project_test.go
@@ -0,0 +1,82 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsProjectTypeValid(t *testing.T) {
+ const UnknownType ProjectType = 15
+
+ var cases = []struct {
+ typ ProjectType
+ valid bool
+ }{
+ {ProjectTypeIndividual, false},
+ {ProjectTypeRepository, true},
+ {ProjectTypeOrganization, false},
+ {UnknownType, false},
+ }
+
+ for _, v := range cases {
+ assert.Equal(t, v.valid, IsProjectTypeValid(v.typ))
+ }
+}
+
+func TestGetProjects(t *testing.T) {
+ assert.NoError(t, PrepareTestDatabase())
+
+ projects, _, err := GetProjects(ProjectSearchOptions{RepoID: 1})
+ assert.NoError(t, err)
+
+ // 1 value for this repo exists in the fixtures
+ assert.Len(t, projects, 1)
+
+ projects, _, err = GetProjects(ProjectSearchOptions{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, PrepareTestDatabase())
+
+ project := &Project{
+ Type: ProjectTypeRepository,
+ BoardType: ProjectBoardTypeBasicKanban,
+ Title: "New Project",
+ RepoID: 1,
+ CreatedUnix: timeutil.TimeStampNow(),
+ CreatorID: 2,
+ }
+
+ assert.NoError(t, NewProject(project))
+
+ _, err := GetProjectByID(project.ID)
+ assert.NoError(t, err)
+
+ // Update project
+ project.Title = "Updated title"
+ assert.NoError(t, UpdateProject(project))
+
+ projectFromDB, err := GetProjectByID(project.ID)
+ assert.NoError(t, err)
+
+ assert.Equal(t, project.Title, projectFromDB.Title)
+
+ assert.NoError(t, ChangeProjectStatus(project, true))
+
+ // Retrieve from DB afresh to check if it is truly closed
+ projectFromDB, err = GetProjectByID(project.ID)
+ assert.NoError(t, err)
+
+ assert.True(t, projectFromDB.IsClosed)
+}
diff --git a/models/repo.go b/models/repo.go
index 9f7ce8af1e..146868d876 100644
--- a/models/repo.go
+++ b/models/repo.go
@@ -168,6 +168,9 @@ type Repository struct {
NumMilestones int `xorm:"NOT NULL DEFAULT 0"`
NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"`
NumOpenMilestones int `xorm:"-"`
+ NumProjects int `xorm:"NOT NULL DEFAULT 0"`
+ NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"`
+ NumOpenProjects int `xorm:"-"`
IsPrivate bool `xorm:"INDEX"`
IsEmpty bool `xorm:"INDEX"`
@@ -237,6 +240,7 @@ func (repo *Repository) AfterLoad() {
repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues
repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls
repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones
+ repo.NumOpenProjects = repo.NumProjects - repo.NumClosedProjects
}
// MustOwner always returns a valid *User object to avoid
@@ -307,6 +311,8 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool)
parent = repo.BaseRepo.innerAPIFormat(e, mode, true)
}
}
+
+ //check enabled/disabled units
hasIssues := false
var externalTracker *api.ExternalTracker
var internalTracker *api.InternalTracker
@@ -353,6 +359,10 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool)
allowRebaseMerge = config.AllowRebaseMerge
allowSquash = config.AllowSquash
}
+ hasProjects := false
+ if _, err := repo.getUnit(e, UnitTypeProjects); err == nil {
+ hasProjects = true
+ }
repo.mustOwner(e)
@@ -390,6 +400,7 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool)
ExternalTracker: externalTracker,
InternalTracker: internalTracker,
HasWiki: hasWiki,
+ HasProjects: hasProjects,
ExternalWiki: externalWiki,
HasPullRequests: hasPullRequests,
IgnoreWhitespaceConflicts: ignoreWhitespaceConflicts,
@@ -1641,6 +1652,18 @@ func DeleteRepository(doer *User, uid, repoID int64) error {
}
}
+ projects, _, err := getProjects(sess, ProjectSearchOptions{
+ RepoID: repoID,
+ })
+ if err != nil {
+ return fmt.Errorf("get projects: %v", err)
+ }
+ for i := range projects {
+ if err := deleteProjectByID(sess, projects[i].ID); err != nil {
+ return fmt.Errorf("delete project [%d]: %v", projects[i].ID, err)
+ }
+ }
+
// FIXME: Remove repository files should be executed after transaction succeed.
repoPath := repo.RepoPath()
removeAllWithNotice(sess, "Delete repository files", repoPath)
diff --git a/models/repo_unit.go b/models/repo_unit.go
index 42ce8f6c8d..d4c74515f7 100644
--- a/models/repo_unit.go
+++ b/models/repo_unit.go
@@ -118,7 +118,7 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
switch colName {
case "type":
switch UnitType(Cell2Int64(val)) {
- case UnitTypeCode, UnitTypeReleases, UnitTypeWiki:
+ case UnitTypeCode, UnitTypeReleases, UnitTypeWiki, UnitTypeProjects:
r.Config = new(UnitConfig)
case UnitTypeExternalWiki:
r.Config = new(ExternalWikiConfig)
diff --git a/models/unit.go b/models/unit.go
index bd2e6b13a6..939deba574 100644
--- a/models/unit.go
+++ b/models/unit.go
@@ -24,6 +24,7 @@ const (
UnitTypeWiki // 5 Wiki
UnitTypeExternalWiki // 6 ExternalWiki
UnitTypeExternalTracker // 7 ExternalTracker
+ UnitTypeProjects // 8 Kanban board
)
// Value returns integer value for unit type
@@ -47,6 +48,8 @@ func (u UnitType) String() string {
return "UnitTypeExternalWiki"
case UnitTypeExternalTracker:
return "UnitTypeExternalTracker"
+ case UnitTypeProjects:
+ return "UnitTypeProjects"
}
return fmt.Sprintf("Unknown UnitType %d", u)
}
@@ -68,6 +71,7 @@ var (
UnitTypeWiki,
UnitTypeExternalWiki,
UnitTypeExternalTracker,
+ UnitTypeProjects,
}
// DefaultRepoUnits contains the default unit types
@@ -77,6 +81,7 @@ var (
UnitTypePullRequests,
UnitTypeReleases,
UnitTypeWiki,
+ UnitTypeProjects,
}
// NotAllowedDefaultRepoUnits contains units that can't be default
@@ -242,6 +247,14 @@ var (
4,
}
+ UnitProjects = Unit{
+ UnitTypeProjects,
+ "repo.projects",
+ "/projects",
+ "repo.projects.desc",
+ 5,
+ }
+
// Units contains all the units
Units = map[UnitType]Unit{
UnitTypeCode: UnitCode,
@@ -251,6 +264,7 @@ var (
UnitTypeReleases: UnitReleases,
UnitTypeWiki: UnitWiki,
UnitTypeExternalWiki: UnitExternalWiki,
+ UnitTypeProjects: UnitProjects,
}
)