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>
This commit is contained in:
Lanre Adelowo 2020-08-17 04:07:38 +01:00 committed by GitHub
parent d285b5d35a
commit 4027c5dd7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 3569 additions and 58 deletions

View file

@ -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)
}
// _____ .__.__ __
// / \ |__| | ____ _______/ |_ ____ ____ ____
// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
-

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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

85
models/migrations/v146.go Normal file
View file

@ -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))
}

View file

@ -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"}

307
models/project.go Normal file
View file

@ -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)
}

220
models/project_board.go Normal file
View file

@ -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
}

210
models/project_issue.go Normal file
View file

@ -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
}

82
models/project_test.go Normal file
View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -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,
}
)