mirror of
				https://github.com/go-gitea/gitea
				synced 2025-11-03 21:08:25 +00:00 
			
		
		
		
	Backport #33594 by lunny --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		@@ -49,6 +49,21 @@ func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
 | 
				
			|||||||
	return ip.ProjectColumnID, nil
 | 
						return ip.ProjectColumnID, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) {
 | 
				
			||||||
 | 
						issues := make([]project_model.ProjectIssue, 0)
 | 
				
			||||||
 | 
						if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&issues); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						result := make(map[int64]int64, len(issues))
 | 
				
			||||||
 | 
						for _, issue := range issues {
 | 
				
			||||||
 | 
							if issue.ProjectColumnID == 0 {
 | 
				
			||||||
 | 
								issue.ProjectColumnID = defaultColumnID
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							result[issue.IssueID] = issue.ProjectColumnID
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return result, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// LoadIssuesFromColumn load issues assigned to this column
 | 
					// LoadIssuesFromColumn load issues assigned to this column
 | 
				
			||||||
func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) {
 | 
					func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) {
 | 
				
			||||||
	issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
 | 
						issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
 | 
				
			||||||
@@ -61,11 +76,11 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if b.Default {
 | 
						if b.Default {
 | 
				
			||||||
		issues, err := Issues(ctx, &IssuesOptions{
 | 
							issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
 | 
				
			||||||
			ProjectColumnID: db.NoConditionID,
 | 
								o.ProjectColumnID = db.NoConditionID
 | 
				
			||||||
			ProjectID:       b.ProjectID,
 | 
								o.ProjectID = b.ProjectID
 | 
				
			||||||
			SortType:        "project-column-sorting",
 | 
								o.SortType = "project-column-sorting"
 | 
				
			||||||
		})
 | 
							}))
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return nil, err
 | 
								return nil, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -79,19 +94,6 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
 | 
				
			|||||||
	return issueList, nil
 | 
						return issueList, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// LoadIssuesFromColumnList load issues assigned to the columns
 | 
					 | 
				
			||||||
func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, opts *IssuesOptions) (map[int64]IssueList, error) {
 | 
					 | 
				
			||||||
	issuesMap := make(map[int64]IssueList, len(bs))
 | 
					 | 
				
			||||||
	for i := range bs {
 | 
					 | 
				
			||||||
		il, err := LoadIssuesFromColumn(ctx, bs[i], opts)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return nil, err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		issuesMap[bs[i].ID] = il
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return issuesMap, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// IssueAssignOrRemoveProject changes the project associated with an issue
 | 
					// IssueAssignOrRemoveProject changes the project associated with an issue
 | 
				
			||||||
// If newProjectID is 0, the issue is removed from the project
 | 
					// If newProjectID is 0, the issue is removed from the project
 | 
				
			||||||
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
 | 
					func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
 | 
				
			||||||
@@ -112,7 +114,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
 | 
				
			|||||||
				return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
 | 
									return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			if newColumnID == 0 {
 | 
								if newColumnID == 0 {
 | 
				
			||||||
				newDefaultColumn, err := newProject.GetDefaultColumn(ctx)
 | 
									newDefaultColumn, err := newProject.MustDefaultColumn(ctx)
 | 
				
			||||||
				if err != nil {
 | 
									if err != nil {
 | 
				
			||||||
					return err
 | 
										return err
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -49,9 +49,9 @@ type IssuesOptions struct { //nolint
 | 
				
			|||||||
	// prioritize issues from this repo
 | 
						// prioritize issues from this repo
 | 
				
			||||||
	PriorityRepoID int64
 | 
						PriorityRepoID int64
 | 
				
			||||||
	IsArchived     optional.Option[bool]
 | 
						IsArchived     optional.Option[bool]
 | 
				
			||||||
	Org            *organization.Organization // issues permission scope
 | 
						Owner          *user_model.User   // issues permission scope, it could be an organization or a user
 | 
				
			||||||
	Team           *organization.Team // issues permission scope
 | 
						Team           *organization.Team // issues permission scope
 | 
				
			||||||
	User           *user_model.User           // issues permission scope
 | 
						Doer           *user_model.User   // issues permission scope
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Copy returns a copy of the options.
 | 
					// Copy returns a copy of the options.
 | 
				
			||||||
@@ -273,8 +273,12 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	applyLabelsCondition(sess, opts)
 | 
						applyLabelsCondition(sess, opts)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if opts.User != nil {
 | 
						if opts.Owner != nil {
 | 
				
			||||||
		sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.Value()))
 | 
							sess.And(repo_model.UserOwnedRepoCond(opts.Owner.ID))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if opts.Doer != nil && !opts.Doer.IsAdmin {
 | 
				
			||||||
 | 
							sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.Doer.ID, opts.Owner, opts.Team, opts.IsPull.Value()))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -321,20 +325,20 @@ func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Typ
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// issuePullAccessibleRepoCond userID must not be zero, this condition require join repository table
 | 
					// issuePullAccessibleRepoCond userID must not be zero, this condition require join repository table
 | 
				
			||||||
func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organization.Organization, team *organization.Team, isPull bool) builder.Cond {
 | 
					func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_model.User, team *organization.Team, isPull bool) builder.Cond {
 | 
				
			||||||
	cond := builder.NewCond()
 | 
						cond := builder.NewCond()
 | 
				
			||||||
	unitType := unit.TypeIssues
 | 
						unitType := unit.TypeIssues
 | 
				
			||||||
	if isPull {
 | 
						if isPull {
 | 
				
			||||||
		unitType = unit.TypePullRequests
 | 
							unitType = unit.TypePullRequests
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if org != nil {
 | 
						if owner != nil && owner.IsOrganization() {
 | 
				
			||||||
		if team != nil {
 | 
							if team != nil {
 | 
				
			||||||
			cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, org.ID, team.ID, unitType)) // special team member repos
 | 
								cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, owner.ID, team.ID, unitType)) // special team member repos
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			cond = cond.And(
 | 
								cond = cond.And(
 | 
				
			||||||
				builder.Or(
 | 
									builder.Or(
 | 
				
			||||||
					repo_model.UserOrgUnitRepoCond(repoIDstr, userID, org.ID, unitType), // team member repos
 | 
										repo_model.UserOrgUnitRepoCond(repoIDstr, userID, owner.ID, unitType), // team member repos
 | 
				
			||||||
					repo_model.UserOrgPublicUnitRepoCond(userID, org.ID),                // user org public non-member repos, TODO: check repo has issues
 | 
										repo_model.UserOrgPublicUnitRepoCond(userID, owner.ID),                // user org public non-member repos, TODO: check repo has issues
 | 
				
			||||||
				),
 | 
									),
 | 
				
			||||||
			)
 | 
								)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,6 +48,8 @@ type Column struct {
 | 
				
			|||||||
	ProjectID int64 `xorm:"INDEX NOT NULL"`
 | 
						ProjectID int64 `xorm:"INDEX NOT NULL"`
 | 
				
			||||||
	CreatorID int64 `xorm:"NOT NULL"`
 | 
						CreatorID int64 `xorm:"NOT NULL"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						NumIssues int64 `xorm:"-"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
 | 
						CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
 | 
				
			||||||
	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
 | 
						UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -57,20 +59,6 @@ func (Column) TableName() string {
 | 
				
			|||||||
	return "project_board" // TODO: the legacy table name should be project_column
 | 
						return "project_board" // TODO: the legacy table name should be project_column
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// NumIssues return counter of all issues assigned to the column
 | 
					 | 
				
			||||||
func (c *Column) NumIssues(ctx context.Context) int {
 | 
					 | 
				
			||||||
	total, err := db.GetEngine(ctx).Table("project_issue").
 | 
					 | 
				
			||||||
		Where("project_id=?", c.ProjectID).
 | 
					 | 
				
			||||||
		And("project_board_id=?", c.ID).
 | 
					 | 
				
			||||||
		GroupBy("issue_id").
 | 
					 | 
				
			||||||
		Cols("issue_id").
 | 
					 | 
				
			||||||
		Count()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return 0
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return int(total)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
 | 
					func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
 | 
				
			||||||
	issues := make([]*ProjectIssue, 0, 5)
 | 
						issues := make([]*ProjectIssue, 0, 5)
 | 
				
			||||||
	if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
 | 
						if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
 | 
				
			||||||
@@ -192,7 +180,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	defaultColumn, err := project.GetDefaultColumn(ctx)
 | 
						defaultColumn, err := project.MustDefaultColumn(ctx)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -257,8 +245,8 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
 | 
				
			|||||||
	return columns, nil
 | 
						return columns, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GetDefaultColumn return default column and ensure only one exists
 | 
					// getDefaultColumn return default column and ensure only one exists
 | 
				
			||||||
func (p *Project) GetDefaultColumn(ctx context.Context) (*Column, error) {
 | 
					func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) {
 | 
				
			||||||
	var column Column
 | 
						var column Column
 | 
				
			||||||
	has, err := db.GetEngine(ctx).
 | 
						has, err := db.GetEngine(ctx).
 | 
				
			||||||
		Where("project_id=? AND `default` = ?", p.ID, true).
 | 
							Where("project_id=? AND `default` = ?", p.ID, true).
 | 
				
			||||||
@@ -270,6 +258,33 @@ func (p *Project) GetDefaultColumn(ctx context.Context) (*Column, error) {
 | 
				
			|||||||
	if has {
 | 
						if has {
 | 
				
			||||||
		return &column, nil
 | 
							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
 | 
				
			||||||
 | 
					func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) {
 | 
				
			||||||
 | 
						c, err := p.getDefaultColumn(ctx)
 | 
				
			||||||
 | 
						if err != nil && !IsErrProjectColumnNotExist(err) {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if c != nil {
 | 
				
			||||||
 | 
							return c, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var column Column
 | 
				
			||||||
 | 
						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
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// create a default column if none is found
 | 
						// create a default column if none is found
 | 
				
			||||||
	column = Column{
 | 
						column = Column{
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,19 +20,19 @@ func TestGetDefaultColumn(t *testing.T) {
 | 
				
			|||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// check if default column was added
 | 
						// check if default column was added
 | 
				
			||||||
	column, err := projectWithoutDefault.GetDefaultColumn(db.DefaultContext)
 | 
						column, err := projectWithoutDefault.MustDefaultColumn(db.DefaultContext)
 | 
				
			||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
	assert.Equal(t, int64(5), column.ProjectID)
 | 
						assert.Equal(t, int64(5), column.ProjectID)
 | 
				
			||||||
	assert.Equal(t, "Uncategorized", column.Title)
 | 
						assert.Equal(t, "Done", column.Title)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6)
 | 
						projectWithMultipleDefaults, err := GetProjectByID(db.DefaultContext, 6)
 | 
				
			||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// check if multiple defaults were removed
 | 
						// check if multiple defaults were removed
 | 
				
			||||||
	column, err = projectWithMultipleDefaults.GetDefaultColumn(db.DefaultContext)
 | 
						column, err = projectWithMultipleDefaults.MustDefaultColumn(db.DefaultContext)
 | 
				
			||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
	assert.Equal(t, int64(6), column.ProjectID)
 | 
						assert.Equal(t, int64(6), column.ProjectID)
 | 
				
			||||||
	assert.Equal(t, int64(9), column.ID)
 | 
						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
 | 
						// set 8 as default column
 | 
				
			||||||
	assert.NoError(t, SetDefaultColumn(db.DefaultContext, column.ProjectID, 8))
 | 
						assert.NoError(t, SetDefaultColumn(db.DefaultContext, column.ProjectID, 8))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,6 @@ import (
 | 
				
			|||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"code.gitea.io/gitea/models/db"
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/log"
 | 
					 | 
				
			||||||
	"code.gitea.io/gitea/modules/util"
 | 
						"code.gitea.io/gitea/modules/util"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -34,48 +33,6 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
 | 
				
			|||||||
	return err
 | 
						return err
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// NumIssues return counter of all issues assigned to a project
 | 
					 | 
				
			||||||
func (p *Project) NumIssues(ctx context.Context) int {
 | 
					 | 
				
			||||||
	c, err := db.GetEngine(ctx).Table("project_issue").
 | 
					 | 
				
			||||||
		Where("project_id=?", p.ID).
 | 
					 | 
				
			||||||
		GroupBy("issue_id").
 | 
					 | 
				
			||||||
		Cols("issue_id").
 | 
					 | 
				
			||||||
		Count()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Error("NumIssues: %v", err)
 | 
					 | 
				
			||||||
		return 0
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return int(c)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// NumClosedIssues return counter of closed issues assigned to a project
 | 
					 | 
				
			||||||
func (p *Project) NumClosedIssues(ctx context.Context) int {
 | 
					 | 
				
			||||||
	c, err := db.GetEngine(ctx).Table("project_issue").
 | 
					 | 
				
			||||||
		Join("INNER", "issue", "project_issue.issue_id=issue.id").
 | 
					 | 
				
			||||||
		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
 | 
					 | 
				
			||||||
		Cols("issue_id").
 | 
					 | 
				
			||||||
		Count()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Error("NumClosedIssues: %v", err)
 | 
					 | 
				
			||||||
		return 0
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return int(c)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// NumOpenIssues return counter of open issues assigned to a project
 | 
					 | 
				
			||||||
func (p *Project) NumOpenIssues(ctx context.Context) int {
 | 
					 | 
				
			||||||
	c, err := db.GetEngine(ctx).Table("project_issue").
 | 
					 | 
				
			||||||
		Join("INNER", "issue", "project_issue.issue_id=issue.id").
 | 
					 | 
				
			||||||
		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).
 | 
					 | 
				
			||||||
		Cols("issue_id").
 | 
					 | 
				
			||||||
		Count()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Error("NumOpenIssues: %v", err)
 | 
					 | 
				
			||||||
		return 0
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return int(c)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
 | 
					func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
 | 
				
			||||||
	if c.ProjectID != newColumn.ProjectID {
 | 
						if c.ProjectID != newColumn.ProjectID {
 | 
				
			||||||
		return fmt.Errorf("columns have to be in the same project")
 | 
							return fmt.Errorf("columns have to be in the same project")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -97,6 +97,9 @@ type Project struct {
 | 
				
			|||||||
	Type         Type
 | 
						Type         Type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	RenderedContent template.HTML `xorm:"-"`
 | 
						RenderedContent template.HTML `xorm:"-"`
 | 
				
			||||||
 | 
						NumOpenIssues   int64         `xorm:"-"`
 | 
				
			||||||
 | 
						NumClosedIssues int64         `xorm:"-"`
 | 
				
			||||||
 | 
						NumIssues       int64         `xorm:"-"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	CreatedUnix    timeutil.TimeStamp `xorm:"INDEX created"`
 | 
						CreatedUnix    timeutil.TimeStamp `xorm:"INDEX created"`
 | 
				
			||||||
	UpdatedUnix    timeutil.TimeStamp `xorm:"INDEX updated"`
 | 
						UpdatedUnix    timeutil.TimeStamp `xorm:"INDEX updated"`
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -73,9 +73,9 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
 | 
				
			|||||||
		UpdatedBeforeUnix:  options.UpdatedBeforeUnix.Value(),
 | 
							UpdatedBeforeUnix:  options.UpdatedBeforeUnix.Value(),
 | 
				
			||||||
		PriorityRepoID:     0,
 | 
							PriorityRepoID:     0,
 | 
				
			||||||
		IsArchived:         options.IsArchived,
 | 
							IsArchived:         options.IsArchived,
 | 
				
			||||||
		Org:                nil,
 | 
							Owner:              nil,
 | 
				
			||||||
		Team:               nil,
 | 
							Team:               nil,
 | 
				
			||||||
		User:               nil,
 | 
							Doer:               nil,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
 | 
						if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -78,6 +78,11 @@ func Projects(ctx *context.Context) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
 | 
				
			||||||
 | 
							ctx.ServerError("LoadIssueNumbersForProjects", err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{
 | 
						opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{
 | 
				
			||||||
		OwnerID:  ctx.ContextUser.ID,
 | 
							OwnerID:  ctx.ContextUser.ID,
 | 
				
			||||||
		IsClosed: optional.Some(!isShowClosed),
 | 
							IsClosed: optional.Some(!isShowClosed),
 | 
				
			||||||
@@ -328,6 +333,10 @@ func ViewProject(ctx *context.Context) {
 | 
				
			|||||||
		ctx.NotFound("", nil)
 | 
							ctx.NotFound("", nil)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						if err := project.LoadOwner(ctx); err != nil {
 | 
				
			||||||
 | 
							ctx.ServerError("LoadOwner", err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	columns, err := project.GetColumns(ctx)
 | 
						columns, err := project.GetColumns(ctx)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -341,14 +350,21 @@ func ViewProject(ctx *context.Context) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 | 
						assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
 | 
						opts := issues_model.IssuesOptions{
 | 
				
			||||||
		LabelIDs:   labelIDs,
 | 
							LabelIDs:   labelIDs,
 | 
				
			||||||
		AssigneeID: optional.Some(assigneeID),
 | 
							AssigneeID: optional.Some(assigneeID),
 | 
				
			||||||
	})
 | 
							Owner:      project.Owner,
 | 
				
			||||||
 | 
							Doer:       ctx.Doer,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		ctx.ServerError("LoadIssuesOfColumns", err)
 | 
							ctx.ServerError("LoadIssuesOfColumns", err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						for _, column := range columns {
 | 
				
			||||||
 | 
							column.NumIssues = int64(len(issuesMap[column.ID]))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if project.CardType != project_model.CardTypeTextOnly {
 | 
						if project.CardType != project_model.CardTypeTextOnly {
 | 
				
			||||||
		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 | 
							issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -92,6 +92,11 @@ func Projects(ctx *context.Context) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil {
 | 
				
			||||||
 | 
							ctx.ServerError("LoadIssueNumbersForProjects", err)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for i := range projects {
 | 
						for i := range projects {
 | 
				
			||||||
		rctx := renderhelper.NewRenderContextRepoComment(ctx, repo)
 | 
							rctx := renderhelper.NewRenderContextRepoComment(ctx, repo)
 | 
				
			||||||
		projects[i].RenderedContent, err = markdown.RenderString(rctx, projects[i].Description)
 | 
							projects[i].RenderedContent, err = markdown.RenderString(rctx, projects[i].Description)
 | 
				
			||||||
@@ -312,7 +317,8 @@ func ViewProject(ctx *context.Context) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 | 
						assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
 | 
						issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
							RepoIDs:    []int64{ctx.Repo.Repository.ID},
 | 
				
			||||||
		LabelIDs:   labelIDs,
 | 
							LabelIDs:   labelIDs,
 | 
				
			||||||
		AssigneeID: optional.Some(assigneeID),
 | 
							AssigneeID: optional.Some(assigneeID),
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
@@ -320,6 +326,9 @@ func ViewProject(ctx *context.Context) {
 | 
				
			|||||||
		ctx.ServerError("LoadIssuesOfColumns", err)
 | 
							ctx.ServerError("LoadIssuesOfColumns", err)
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						for _, column := range columns {
 | 
				
			||||||
 | 
							column.NumIssues = int64(len(issuesMap[column.ID]))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if project.CardType != project_model.CardTypeTextOnly {
 | 
						if project.CardType != project_model.CardTypeTextOnly {
 | 
				
			||||||
		issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
 | 
							issuesAttachmentMap := make(map[int64][]*repo_model.Attachment)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -419,7 +419,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 | 
				
			|||||||
		IsPull:     optional.Some(isPullList),
 | 
							IsPull:     optional.Some(isPullList),
 | 
				
			||||||
		SortType:   sortType,
 | 
							SortType:   sortType,
 | 
				
			||||||
		IsArchived: optional.Some(false),
 | 
							IsArchived: optional.Some(false),
 | 
				
			||||||
		User:       ctx.Doer,
 | 
							Doer:       ctx.Doer,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	// --------------------------------------------------------------------------
 | 
						// --------------------------------------------------------------------------
 | 
				
			||||||
	// Build opts (IssuesOptions), which contains filter information.
 | 
						// Build opts (IssuesOptions), which contains filter information.
 | 
				
			||||||
@@ -431,7 +431,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Get repository IDs where User/Org/Team has access.
 | 
						// Get repository IDs where User/Org/Team has access.
 | 
				
			||||||
	if ctx.Org != nil && ctx.Org.Organization != nil {
 | 
						if ctx.Org != nil && ctx.Org.Organization != nil {
 | 
				
			||||||
		opts.Org = ctx.Org.Organization
 | 
							opts.Owner = ctx.Org.Organization.AsUser()
 | 
				
			||||||
		opts.Team = ctx.Org.Team
 | 
							opts.Team = ctx.Org.Team
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
 | 
							issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ import (
 | 
				
			|||||||
	issues_model "code.gitea.io/gitea/models/issues"
 | 
						issues_model "code.gitea.io/gitea/models/issues"
 | 
				
			||||||
	project_model "code.gitea.io/gitea/models/project"
 | 
						project_model "code.gitea.io/gitea/models/project"
 | 
				
			||||||
	user_model "code.gitea.io/gitea/models/user"
 | 
						user_model "code.gitea.io/gitea/models/user"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/modules/optional"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
 | 
					// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
 | 
				
			||||||
@@ -84,3 +85,123 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
 | 
				
			|||||||
		return nil
 | 
							return nil
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// LoadIssuesFromProject load issues assigned to each project column inside the given project
 | 
				
			||||||
 | 
					func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (map[int64]issues_model.IssueList, error) {
 | 
				
			||||||
 | 
						issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
 | 
				
			||||||
 | 
							o.ProjectID = project.ID
 | 
				
			||||||
 | 
							o.SortType = "project-column-sorting"
 | 
				
			||||||
 | 
						}))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := issueList.LoadComments(ctx); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						defaultColumn, err := project.MustDefaultColumn(ctx)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						issueColumnMap, err := issues_model.LoadProjectIssueColumnMap(ctx, project.ID, defaultColumn.ID)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						results := make(map[int64]issues_model.IssueList)
 | 
				
			||||||
 | 
						for _, issue := range issueList {
 | 
				
			||||||
 | 
							projectColumnID, ok := issueColumnMap[issue.ID]
 | 
				
			||||||
 | 
							if !ok {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if _, ok := results[projectColumnID]; !ok {
 | 
				
			||||||
 | 
								results[projectColumnID] = make(issues_model.IssueList, 0)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							results[projectColumnID] = append(results[projectColumnID], issue)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return results, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// NumClosedIssues return counter of closed issues assigned to a project
 | 
				
			||||||
 | 
					func loadNumClosedIssues(ctx context.Context, p *project_model.Project) error {
 | 
				
			||||||
 | 
						cnt, err := db.GetEngine(ctx).Table("project_issue").
 | 
				
			||||||
 | 
							Join("INNER", "issue", "project_issue.issue_id=issue.id").
 | 
				
			||||||
 | 
							Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
 | 
				
			||||||
 | 
							Cols("issue_id").
 | 
				
			||||||
 | 
							Count()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						p.NumClosedIssues = cnt
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// NumOpenIssues return counter of open issues assigned to a project
 | 
				
			||||||
 | 
					func loadNumOpenIssues(ctx context.Context, p *project_model.Project) error {
 | 
				
			||||||
 | 
						cnt, err := db.GetEngine(ctx).Table("project_issue").
 | 
				
			||||||
 | 
							Join("INNER", "issue", "project_issue.issue_id=issue.id").
 | 
				
			||||||
 | 
							Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).
 | 
				
			||||||
 | 
							Cols("issue_id").
 | 
				
			||||||
 | 
							Count()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						p.NumOpenIssues = cnt
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func LoadIssueNumbersForProjects(ctx context.Context, projects []*project_model.Project, doer *user_model.User) error {
 | 
				
			||||||
 | 
						for _, project := range projects {
 | 
				
			||||||
 | 
							if err := LoadIssueNumbersForProject(ctx, project, doer); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Project, doer *user_model.User) error {
 | 
				
			||||||
 | 
						// for repository project, just get the numbers
 | 
				
			||||||
 | 
						if project.OwnerID == 0 {
 | 
				
			||||||
 | 
							if err := loadNumClosedIssues(ctx, project); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if err := loadNumOpenIssues(ctx, project); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							project.NumIssues = project.NumClosedIssues + project.NumOpenIssues
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := project.LoadOwner(ctx); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// for user or org projects, we need to check access permissions
 | 
				
			||||||
 | 
						opts := issues_model.IssuesOptions{
 | 
				
			||||||
 | 
							ProjectID: project.ID,
 | 
				
			||||||
 | 
							Doer:      doer,
 | 
				
			||||||
 | 
							AllPublic: doer == nil,
 | 
				
			||||||
 | 
							Owner:     project.Owner,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var err error
 | 
				
			||||||
 | 
						project.NumOpenIssues, err = issues_model.CountIssues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
 | 
				
			||||||
 | 
							o.IsClosed = optional.Some(false)
 | 
				
			||||||
 | 
						}))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						project.NumClosedIssues, err = issues_model.CountIssues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
 | 
				
			||||||
 | 
							o.IsClosed = optional.Some(true)
 | 
				
			||||||
 | 
						}))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						project.NumIssues = project.NumClosedIssues + project.NumOpenIssues
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										210
									
								
								services/projects/issue_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								services/projects/issue_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,210 @@
 | 
				
			|||||||
 | 
					// Copyright 2025 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					package project
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"testing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
 | 
						issues_model "code.gitea.io/gitea/models/issues"
 | 
				
			||||||
 | 
						org_model "code.gitea.io/gitea/models/organization"
 | 
				
			||||||
 | 
						project_model "code.gitea.io/gitea/models/project"
 | 
				
			||||||
 | 
						repo_model "code.gitea.io/gitea/models/repo"
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/unittest"
 | 
				
			||||||
 | 
						user_model "code.gitea.io/gitea/models/user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/stretchr/testify/assert"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func Test_Projects(t *testing.T) {
 | 
				
			||||||
 | 
						assert.NoError(t, unittest.PrepareTestDatabase())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
				
			||||||
 | 
						user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 | 
				
			||||||
 | 
						org3 := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: 3})
 | 
				
			||||||
 | 
						user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						t.Run("User projects", func(t *testing.T) {
 | 
				
			||||||
 | 
							pi1 := project_model.ProjectIssue{
 | 
				
			||||||
 | 
								ProjectID:       4,
 | 
				
			||||||
 | 
								IssueID:         1,
 | 
				
			||||||
 | 
								ProjectColumnID: 4,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err := db.Insert(db.DefaultContext, &pi1)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							defer func() {
 | 
				
			||||||
 | 
								_, err = db.DeleteByID[project_model.ProjectIssue](db.DefaultContext, pi1.ID)
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
							}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							pi2 := project_model.ProjectIssue{
 | 
				
			||||||
 | 
								ProjectID:       4,
 | 
				
			||||||
 | 
								IssueID:         4,
 | 
				
			||||||
 | 
								ProjectColumnID: 4,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err = db.Insert(db.DefaultContext, &pi2)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							defer func() {
 | 
				
			||||||
 | 
								_, err = db.DeleteByID[project_model.ProjectIssue](db.DefaultContext, pi2.ID)
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
							}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{
 | 
				
			||||||
 | 
								OwnerID: user2.ID,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							assert.Len(t, projects, 3)
 | 
				
			||||||
 | 
							assert.EqualValues(t, 4, projects[0].ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									Owner: user2,
 | 
				
			||||||
 | 
									Doer:  user2,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)    // 4 has 2 issues, 6 will not contains here because 0 issues
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[4], 2) // user2 can visit both issues, one from public repository one from private repository
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Anonymous user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									AllPublic: true,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[4], 1) // anonymous user can only visit public repo issues
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									Owner: user2,
 | 
				
			||||||
 | 
									Doer:  user4,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[4], 1) // user4 can only visit public repo issues
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						t.Run("Org projects", func(t *testing.T) {
 | 
				
			||||||
 | 
							project1 := project_model.Project{
 | 
				
			||||||
 | 
								Title:        "project in an org",
 | 
				
			||||||
 | 
								OwnerID:      org3.ID,
 | 
				
			||||||
 | 
								Type:         project_model.TypeOrganization,
 | 
				
			||||||
 | 
								TemplateType: project_model.TemplateTypeBasicKanban,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err := project_model.NewProject(db.DefaultContext, &project1)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							defer func() {
 | 
				
			||||||
 | 
								err := project_model.DeleteProjectByID(db.DefaultContext, project1.ID)
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
							}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							column1 := project_model.Column{
 | 
				
			||||||
 | 
								Title:     "column 1",
 | 
				
			||||||
 | 
								ProjectID: project1.ID,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err = project_model.NewColumn(db.DefaultContext, &column1)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							column2 := project_model.Column{
 | 
				
			||||||
 | 
								Title:     "column 2",
 | 
				
			||||||
 | 
								ProjectID: project1.ID,
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err = project_model.NewColumn(db.DefaultContext, &column2)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// issue 6 belongs to private repo 3 under org 3
 | 
				
			||||||
 | 
							issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
 | 
				
			||||||
 | 
							err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue6, user2, project1.ID, column1.ID)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// issue 16 belongs to public repo 16 under org 3
 | 
				
			||||||
 | 
							issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16})
 | 
				
			||||||
 | 
							err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue16, user2, project1.ID, column1.ID)
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{
 | 
				
			||||||
 | 
								OwnerID: org3.ID,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							assert.Len(t, projects, 1)
 | 
				
			||||||
 | 
							assert.EqualValues(t, project1.ID, projects[0].ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									Owner: org3.AsUser(),
 | 
				
			||||||
 | 
									Doer:  userAdmin,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)             // column1 has 2 issues, 6 will not contains here because 0 issues
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[column1.ID], 2) // user2 can visit both issues, one from public repository one from private repository
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Anonymous user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									AllPublic: true,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[column1.ID], 1) // anonymous user can only visit public repo issues
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									Owner: org3.AsUser(),
 | 
				
			||||||
 | 
									Doer:  user2,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[column1.ID], 1) // user4 can only visit public repo issues
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						t.Run("Repository projects", func(t *testing.T) {
 | 
				
			||||||
 | 
							repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{
 | 
				
			||||||
 | 
								RepoID: repo1.ID,
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							assert.NoError(t, err)
 | 
				
			||||||
 | 
							assert.Len(t, projects, 1)
 | 
				
			||||||
 | 
							assert.EqualValues(t, 1, projects[0].ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									RepoIDs: []int64{repo1.ID},
 | 
				
			||||||
 | 
									Doer:    userAdmin,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 3)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[1], 2)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[2], 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[3], 1)
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Anonymous user", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									AllPublic: true,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 3)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[1], 2)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[2], 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[3], 1)
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) {
 | 
				
			||||||
 | 
								columnIssues, err := LoadIssuesFromProject(db.DefaultContext, projects[0], &issues_model.IssuesOptions{
 | 
				
			||||||
 | 
									RepoIDs: []int64{repo1.ID},
 | 
				
			||||||
 | 
									Doer:    user2,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								assert.NoError(t, err)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues, 3)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[1], 2)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[2], 1)
 | 
				
			||||||
 | 
								assert.Len(t, columnIssues[3], 1)
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										18
									
								
								services/projects/main_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								services/projects/main_test.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					// Copyright 2025 The Gitea Authors. All rights reserved.
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: MIT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					package project
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"testing"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/unittest"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						_ "code.gitea.io/gitea/models"
 | 
				
			||||||
 | 
						_ "code.gitea.io/gitea/models/actions"
 | 
				
			||||||
 | 
						_ "code.gitea.io/gitea/models/activities"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestMain(m *testing.M) {
 | 
				
			||||||
 | 
						unittest.MainTest(m)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -52,11 +52,11 @@
 | 
				
			|||||||
				<div class="group">
 | 
									<div class="group">
 | 
				
			||||||
					<div class="flex-text-block">
 | 
										<div class="flex-text-block">
 | 
				
			||||||
						{{svg "octicon-issue-opened" 14}}
 | 
											{{svg "octicon-issue-opened" 14}}
 | 
				
			||||||
						{{ctx.Locale.PrettyNumber (.NumOpenIssues ctx)}} {{ctx.Locale.Tr "repo.issues.open_title"}}
 | 
											{{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
					<div class="flex-text-block">
 | 
										<div class="flex-text-block">
 | 
				
			||||||
						{{svg "octicon-check" 14}}
 | 
											{{svg "octicon-check" 14}}
 | 
				
			||||||
						{{ctx.Locale.PrettyNumber (.NumClosedIssues ctx)}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
 | 
											{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
				</div>
 | 
									</div>
 | 
				
			||||||
				{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
 | 
									{{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -86,7 +86,7 @@
 | 
				
			|||||||
			<div class="project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 | 
								<div class="project-column"{{if .Color}} style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
 | 
				
			||||||
				<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
 | 
									<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
 | 
				
			||||||
					<div class="ui circular label project-column-issue-count">
 | 
										<div class="ui circular label project-column-issue-count">
 | 
				
			||||||
						{{.NumIssues ctx}}
 | 
											{{.NumIssues}}
 | 
				
			||||||
					</div>
 | 
										</div>
 | 
				
			||||||
					<div class="project-column-title-label gt-ellipsis">{{.Title}}</div>
 | 
										<div class="project-column-title-label gt-ellipsis">{{.Title}}</div>
 | 
				
			||||||
					{{if $canWriteProject}}
 | 
										{{if $canWriteProject}}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -78,7 +78,7 @@ func TestMoveRepoProjectColumns(t *testing.T) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	columnsAfter, err := project1.GetColumns(db.DefaultContext)
 | 
						columnsAfter, err := project1.GetColumns(db.DefaultContext)
 | 
				
			||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
	assert.Len(t, columns, 3)
 | 
						assert.Len(t, columnsAfter, 3)
 | 
				
			||||||
	assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
 | 
						assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
 | 
				
			||||||
	assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
 | 
						assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
 | 
				
			||||||
	assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
 | 
						assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user