mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 12:37:14 -04: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