code.gitea.io/gitea@v1.22.3/models/project/board.go (about)

     1  // Copyright 2020 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package project
     5  
     6  import (
     7  	"context"
     8  	"errors"
     9  	"fmt"
    10  	"regexp"
    11  
    12  	"code.gitea.io/gitea/models/db"
    13  	"code.gitea.io/gitea/modules/setting"
    14  	"code.gitea.io/gitea/modules/timeutil"
    15  	"code.gitea.io/gitea/modules/util"
    16  
    17  	"xorm.io/builder"
    18  )
    19  
    20  type (
    21  	// BoardType is used to represent a project board type
    22  	BoardType uint8
    23  
    24  	// CardType is used to represent a project board card type
    25  	CardType uint8
    26  
    27  	// BoardList is a list of all project boards in a repository
    28  	BoardList []*Board
    29  )
    30  
    31  const (
    32  	// BoardTypeNone is a project board type that has no predefined columns
    33  	BoardTypeNone BoardType = iota
    34  
    35  	// BoardTypeBasicKanban is a project board type that has basic predefined columns
    36  	BoardTypeBasicKanban
    37  
    38  	// BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
    39  	BoardTypeBugTriage
    40  )
    41  
    42  const (
    43  	// CardTypeTextOnly is a project board card type that is text only
    44  	CardTypeTextOnly CardType = iota
    45  
    46  	// CardTypeImagesAndText is a project board card type that has images and text
    47  	CardTypeImagesAndText
    48  )
    49  
    50  // BoardColorPattern is a regexp witch can validate BoardColor
    51  var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
    52  
    53  // Board is used to represent boards on a project
    54  type Board struct {
    55  	ID      int64 `xorm:"pk autoincr"`
    56  	Title   string
    57  	Default bool   `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
    58  	Sorting int8   `xorm:"NOT NULL DEFAULT 0"`
    59  	Color   string `xorm:"VARCHAR(7)"`
    60  
    61  	ProjectID int64 `xorm:"INDEX NOT NULL"`
    62  	CreatorID int64 `xorm:"NOT NULL"`
    63  
    64  	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
    65  	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
    66  }
    67  
    68  // TableName return the real table name
    69  func (Board) TableName() string {
    70  	return "project_board"
    71  }
    72  
    73  // NumIssues return counter of all issues assigned to the board
    74  func (b *Board) NumIssues(ctx context.Context) int {
    75  	c, err := db.GetEngine(ctx).Table("project_issue").
    76  		Where("project_id=?", b.ProjectID).
    77  		And("project_board_id=?", b.ID).
    78  		GroupBy("issue_id").
    79  		Cols("issue_id").
    80  		Count()
    81  	if err != nil {
    82  		return 0
    83  	}
    84  	return int(c)
    85  }
    86  
    87  func (b *Board) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
    88  	issues := make([]*ProjectIssue, 0, 5)
    89  	if err := db.GetEngine(ctx).Where("project_id=?", b.ProjectID).
    90  		And("project_board_id=?", b.ID).
    91  		OrderBy("sorting, id").
    92  		Find(&issues); err != nil {
    93  		return nil, err
    94  	}
    95  	return issues, nil
    96  }
    97  
    98  func init() {
    99  	db.RegisterModel(new(Board))
   100  }
   101  
   102  // IsBoardTypeValid checks if the project board type is valid
   103  func IsBoardTypeValid(p BoardType) bool {
   104  	switch p {
   105  	case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage:
   106  		return true
   107  	default:
   108  		return false
   109  	}
   110  }
   111  
   112  // IsCardTypeValid checks if the project board card type is valid
   113  func IsCardTypeValid(p CardType) bool {
   114  	switch p {
   115  	case CardTypeTextOnly, CardTypeImagesAndText:
   116  		return true
   117  	default:
   118  		return false
   119  	}
   120  }
   121  
   122  func createBoardsForProjectsType(ctx context.Context, project *Project) error {
   123  	var items []string
   124  
   125  	switch project.BoardType {
   126  	case BoardTypeBugTriage:
   127  		items = setting.Project.ProjectBoardBugTriageType
   128  
   129  	case BoardTypeBasicKanban:
   130  		items = setting.Project.ProjectBoardBasicKanbanType
   131  	case BoardTypeNone:
   132  		fallthrough
   133  	default:
   134  		return nil
   135  	}
   136  
   137  	board := Board{
   138  		CreatedUnix: timeutil.TimeStampNow(),
   139  		CreatorID:   project.CreatorID,
   140  		Title:       "Backlog",
   141  		ProjectID:   project.ID,
   142  		Default:     true,
   143  	}
   144  	if err := db.Insert(ctx, board); err != nil {
   145  		return err
   146  	}
   147  
   148  	if len(items) == 0 {
   149  		return nil
   150  	}
   151  
   152  	boards := make([]Board, 0, len(items))
   153  
   154  	for _, v := range items {
   155  		boards = append(boards, Board{
   156  			CreatedUnix: timeutil.TimeStampNow(),
   157  			CreatorID:   project.CreatorID,
   158  			Title:       v,
   159  			ProjectID:   project.ID,
   160  		})
   161  	}
   162  
   163  	return db.Insert(ctx, boards)
   164  }
   165  
   166  // maxProjectColumns max columns allowed in a project, this should not bigger than 127
   167  // because sorting is int8 in database
   168  const maxProjectColumns = 20
   169  
   170  // NewBoard adds a new project board to a given project
   171  func NewBoard(ctx context.Context, board *Board) error {
   172  	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
   173  		return fmt.Errorf("bad color code: %s", board.Color)
   174  	}
   175  	res := struct {
   176  		MaxSorting  int64
   177  		ColumnCount int64
   178  	}{}
   179  	if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as column_count").Table("project_board").
   180  		Where("project_id=?", board.ProjectID).Get(&res); err != nil {
   181  		return err
   182  	}
   183  	if res.ColumnCount >= maxProjectColumns {
   184  		return fmt.Errorf("NewBoard: maximum number of columns reached")
   185  	}
   186  	board.Sorting = int8(util.Iif(res.ColumnCount > 0, res.MaxSorting+1, 0))
   187  	_, err := db.GetEngine(ctx).Insert(board)
   188  	return err
   189  }
   190  
   191  // DeleteBoardByID removes all issues references to the project board.
   192  func DeleteBoardByID(ctx context.Context, boardID int64) error {
   193  	ctx, committer, err := db.TxContext(ctx)
   194  	if err != nil {
   195  		return err
   196  	}
   197  	defer committer.Close()
   198  
   199  	if err := deleteBoardByID(ctx, boardID); err != nil {
   200  		return err
   201  	}
   202  
   203  	return committer.Commit()
   204  }
   205  
   206  func deleteBoardByID(ctx context.Context, boardID int64) error {
   207  	board, err := GetBoard(ctx, boardID)
   208  	if err != nil {
   209  		if IsErrProjectBoardNotExist(err) {
   210  			return nil
   211  		}
   212  
   213  		return err
   214  	}
   215  
   216  	if board.Default {
   217  		return fmt.Errorf("deleteBoardByID: cannot delete default board")
   218  	}
   219  
   220  	// move all issues to the default column
   221  	project, err := GetProjectByID(ctx, board.ProjectID)
   222  	if err != nil {
   223  		return err
   224  	}
   225  	defaultColumn, err := project.GetDefaultBoard(ctx)
   226  	if err != nil {
   227  		return err
   228  	}
   229  
   230  	if err = board.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
   231  		return err
   232  	}
   233  
   234  	if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil {
   235  		return err
   236  	}
   237  	return nil
   238  }
   239  
   240  func deleteBoardByProjectID(ctx context.Context, projectID int64) error {
   241  	_, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Board{})
   242  	return err
   243  }
   244  
   245  // GetBoard fetches the current board of a project
   246  func GetBoard(ctx context.Context, boardID int64) (*Board, error) {
   247  	board := new(Board)
   248  	has, err := db.GetEngine(ctx).ID(boardID).Get(board)
   249  	if err != nil {
   250  		return nil, err
   251  	} else if !has {
   252  		return nil, ErrProjectBoardNotExist{BoardID: boardID}
   253  	}
   254  
   255  	return board, nil
   256  }
   257  
   258  // UpdateBoard updates a project board
   259  func UpdateBoard(ctx context.Context, board *Board) error {
   260  	var fieldToUpdate []string
   261  
   262  	if board.Sorting != 0 {
   263  		fieldToUpdate = append(fieldToUpdate, "sorting")
   264  	}
   265  
   266  	if board.Title != "" {
   267  		fieldToUpdate = append(fieldToUpdate, "title")
   268  	}
   269  
   270  	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
   271  		return fmt.Errorf("bad color code: %s", board.Color)
   272  	}
   273  	fieldToUpdate = append(fieldToUpdate, "color")
   274  
   275  	_, err := db.GetEngine(ctx).ID(board.ID).Cols(fieldToUpdate...).Update(board)
   276  
   277  	return err
   278  }
   279  
   280  // GetBoards fetches all boards related to a project
   281  func (p *Project) GetBoards(ctx context.Context) (BoardList, error) {
   282  	boards := make([]*Board, 0, 5)
   283  	if err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Find(&boards); err != nil {
   284  		return nil, err
   285  	}
   286  
   287  	return boards, nil
   288  }
   289  
   290  // GetDefaultBoard return default board and ensure only one exists
   291  func (p *Project) GetDefaultBoard(ctx context.Context) (*Board, error) {
   292  	var board Board
   293  	has, err := db.GetEngine(ctx).
   294  		Where("project_id=? AND `default` = ?", p.ID, true).
   295  		Desc("id").Get(&board)
   296  	if err != nil {
   297  		return nil, err
   298  	}
   299  
   300  	if has {
   301  		return &board, nil
   302  	}
   303  
   304  	// create a default board if none is found
   305  	board = Board{
   306  		ProjectID: p.ID,
   307  		Default:   true,
   308  		Title:     "Uncategorized",
   309  		CreatorID: p.CreatorID,
   310  	}
   311  	if _, err := db.GetEngine(ctx).Insert(&board); err != nil {
   312  		return nil, err
   313  	}
   314  	return &board, nil
   315  }
   316  
   317  // SetDefaultBoard represents a board for issues not assigned to one
   318  func SetDefaultBoard(ctx context.Context, projectID, boardID int64) error {
   319  	return db.WithTx(ctx, func(ctx context.Context) error {
   320  		if _, err := GetBoard(ctx, boardID); err != nil {
   321  			return err
   322  		}
   323  
   324  		if _, err := db.GetEngine(ctx).Where(builder.Eq{
   325  			"project_id": projectID,
   326  			"`default`":  true,
   327  		}).Cols("`default`").Update(&Board{Default: false}); err != nil {
   328  			return err
   329  		}
   330  
   331  		_, err := db.GetEngine(ctx).ID(boardID).
   332  			Where(builder.Eq{"project_id": projectID}).
   333  			Cols("`default`").Update(&Board{Default: true})
   334  		return err
   335  	})
   336  }
   337  
   338  // UpdateBoardSorting update project board sorting
   339  func UpdateBoardSorting(ctx context.Context, bs BoardList) error {
   340  	return db.WithTx(ctx, func(ctx context.Context) error {
   341  		for i := range bs {
   342  			if _, err := db.GetEngine(ctx).ID(bs[i].ID).Cols(
   343  				"sorting",
   344  			).Update(bs[i]); err != nil {
   345  				return err
   346  			}
   347  		}
   348  		return nil
   349  	})
   350  }
   351  
   352  func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (BoardList, error) {
   353  	columns := make([]*Board, 0, 5)
   354  	if err := db.GetEngine(ctx).
   355  		Where("project_id =?", projectID).
   356  		In("id", columnsIDs).
   357  		OrderBy("sorting").Find(&columns); err != nil {
   358  		return nil, err
   359  	}
   360  	return columns, nil
   361  }
   362  
   363  // MoveColumnsOnProject sorts columns in a project
   364  func MoveColumnsOnProject(ctx context.Context, project *Project, sortedColumnIDs map[int64]int64) error {
   365  	return db.WithTx(ctx, func(ctx context.Context) error {
   366  		sess := db.GetEngine(ctx)
   367  		columnIDs := util.ValuesOfMap(sortedColumnIDs)
   368  		movedColumns, err := GetColumnsByIDs(ctx, project.ID, columnIDs)
   369  		if err != nil {
   370  			return err
   371  		}
   372  		if len(movedColumns) != len(sortedColumnIDs) {
   373  			return errors.New("some columns do not exist")
   374  		}
   375  
   376  		for _, column := range movedColumns {
   377  			if column.ProjectID != project.ID {
   378  				return fmt.Errorf("column[%d]'s projectID is not equal to project's ID [%d]", column.ProjectID, project.ID)
   379  			}
   380  		}
   381  
   382  		for sorting, columnID := range sortedColumnIDs {
   383  			if _, err := sess.Exec("UPDATE `project_board` SET sorting=? WHERE id=?", sorting, columnID); err != nil {
   384  				return err
   385  			}
   386  		}
   387  		return nil
   388  	})
   389  }