mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 12:17:24 -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 | ||||
| } | ||||
|  | ||||
| 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 | ||||
| func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) { | ||||
| 	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 { | ||||
| 		issues, err := Issues(ctx, &IssuesOptions{ | ||||
| 			ProjectColumnID: db.NoConditionID, | ||||
| 			ProjectID:       b.ProjectID, | ||||
| 			SortType:        "project-column-sorting", | ||||
| 		}) | ||||
| 		issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { | ||||
| 			o.ProjectColumnID = db.NoConditionID | ||||
| 			o.ProjectID = b.ProjectID | ||||
| 			o.SortType = "project-column-sorting" | ||||
| 		})) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -79,19 +94,6 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is | ||||
| 	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 | ||||
| // 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 { | ||||
| @@ -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) | ||||
| 			} | ||||
| 			if newColumnID == 0 { | ||||
| 				newDefaultColumn, err := newProject.GetDefaultColumn(ctx) | ||||
| 				newDefaultColumn, err := newProject.MustDefaultColumn(ctx) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
|   | ||||
| @@ -49,9 +49,9 @@ type IssuesOptions struct { //nolint | ||||
| 	// prioritize issues from this repo | ||||
| 	PriorityRepoID int64 | ||||
| 	IsArchived     optional.Option[bool] | ||||
| 	Org            *organization.Organization // issues permission scope | ||||
| 	Team           *organization.Team         // issues permission scope | ||||
| 	User           *user_model.User           // issues permission scope | ||||
| 	Owner          *user_model.User   // issues permission scope, it could be an organization or a user | ||||
| 	Team           *organization.Team // issues permission scope | ||||
| 	Doer           *user_model.User   // issues permission scope | ||||
| } | ||||
|  | ||||
| // Copy returns a copy of the options. | ||||
| @@ -273,8 +273,12 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) { | ||||
|  | ||||
| 	applyLabelsCondition(sess, opts) | ||||
|  | ||||
| 	if opts.User != nil { | ||||
| 		sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.Value())) | ||||
| 	if opts.Owner != nil { | ||||
| 		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 | ||||
| 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() | ||||
| 	unitType := unit.TypeIssues | ||||
| 	if isPull { | ||||
| 		unitType = unit.TypePullRequests | ||||
| 	} | ||||
| 	if org != nil { | ||||
| 	if owner != nil && owner.IsOrganization() { | ||||
| 		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 { | ||||
| 			cond = cond.And( | ||||
| 				builder.Or( | ||||
| 					repo_model.UserOrgUnitRepoCond(repoIDstr, userID, org.ID, unitType), // team member repos | ||||
| 					repo_model.UserOrgPublicUnitRepoCond(userID, org.ID),                // user org public non-member repos, TODO: check repo has issues | ||||
| 					repo_model.UserOrgUnitRepoCond(repoIDstr, userID, owner.ID, unitType), // team member repos | ||||
| 					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"` | ||||
| 	CreatorID int64 `xorm:"NOT NULL"` | ||||
|  | ||||
| 	NumIssues int64 `xorm:"-"` | ||||
|  | ||||
| 	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` | ||||
| 	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 | ||||
| } | ||||
|  | ||||
| // 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) { | ||||
| 	issues := make([]*ProjectIssue, 0, 5) | ||||
| 	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 { | ||||
| 		return err | ||||
| 	} | ||||
| 	defaultColumn, err := project.GetDefaultColumn(ctx) | ||||
| 	defaultColumn, err := project.MustDefaultColumn(ctx) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| @@ -257,8 +245,8 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) { | ||||
| 	return columns, nil | ||||
| } | ||||
|  | ||||
| // GetDefaultColumn return default column and ensure only one exists | ||||
| func (p *Project) GetDefaultColumn(ctx context.Context) (*Column, error) { | ||||
| // getDefaultColumn return default column and ensure only one exists | ||||
| func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { | ||||
| 	var column Column | ||||
| 	has, err := db.GetEngine(ctx). | ||||
| 		Where("project_id=? AND `default` = ?", p.ID, true). | ||||
| @@ -270,6 +258,33 @@ func (p *Project) GetDefaultColumn(ctx context.Context) (*Column, error) { | ||||
| 	if has { | ||||
| 		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 | ||||
| 	column = Column{ | ||||
|   | ||||
| @@ -20,19 +20,19 @@ func TestGetDefaultColumn(t *testing.T) { | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// check if default column was added | ||||
| 	column, err := projectWithoutDefault.GetDefaultColumn(db.DefaultContext) | ||||
| 	column, err := projectWithoutDefault.MustDefaultColumn(db.DefaultContext) | ||||
| 	assert.NoError(t, err) | ||||
| 	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) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// check if multiple defaults were removed | ||||
| 	column, err = projectWithMultipleDefaults.GetDefaultColumn(db.DefaultContext) | ||||
| 	column, err = projectWithMultipleDefaults.MustDefaultColumn(db.DefaultContext) | ||||
| 	assert.NoError(t, err) | ||||
| 	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 | ||||
| 	assert.NoError(t, SetDefaultColumn(db.DefaultContext, column.ProjectID, 8)) | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| @@ -34,48 +33,6 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error | ||||
| 	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 { | ||||
| 	if c.ProjectID != newColumn.ProjectID { | ||||
| 		return fmt.Errorf("columns have to be in the same project") | ||||
|   | ||||
| @@ -97,6 +97,9 @@ type Project struct { | ||||
| 	Type         Type | ||||
|  | ||||
| 	RenderedContent template.HTML `xorm:"-"` | ||||
| 	NumOpenIssues   int64         `xorm:"-"` | ||||
| 	NumClosedIssues int64         `xorm:"-"` | ||||
| 	NumIssues       int64         `xorm:"-"` | ||||
|  | ||||
| 	CreatedUnix    timeutil.TimeStamp `xorm:"INDEX created"` | ||||
| 	UpdatedUnix    timeutil.TimeStamp `xorm:"INDEX updated"` | ||||
|   | ||||
| @@ -73,9 +73,9 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m | ||||
| 		UpdatedBeforeUnix:  options.UpdatedBeforeUnix.Value(), | ||||
| 		PriorityRepoID:     0, | ||||
| 		IsArchived:         options.IsArchived, | ||||
| 		Org:                nil, | ||||
| 		Owner:              nil, | ||||
| 		Team:               nil, | ||||
| 		User:               nil, | ||||
| 		Doer:               nil, | ||||
| 	} | ||||
|  | ||||
| 	if len(options.MilestoneIDs) == 1 && options.MilestoneIDs[0] == 0 { | ||||
|   | ||||
| @@ -78,6 +78,11 @@ func Projects(ctx *context.Context) { | ||||
| 		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{ | ||||
| 		OwnerID:  ctx.ContextUser.ID, | ||||
| 		IsClosed: optional.Some(!isShowClosed), | ||||
| @@ -328,6 +333,10 @@ func ViewProject(ctx *context.Context) { | ||||
| 		ctx.NotFound("", nil) | ||||
| 		return | ||||
| 	} | ||||
| 	if err := project.LoadOwner(ctx); err != nil { | ||||
| 		ctx.ServerError("LoadOwner", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	columns, err := project.GetColumns(ctx) | ||||
| 	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 | ||||
|  | ||||
| 	issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ | ||||
| 	opts := issues_model.IssuesOptions{ | ||||
| 		LabelIDs:   labelIDs, | ||||
| 		AssigneeID: optional.Some(assigneeID), | ||||
| 	}) | ||||
| 		Owner:      project.Owner, | ||||
| 		Doer:       ctx.Doer, | ||||
| 	} | ||||
|  | ||||
| 	issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("LoadIssuesOfColumns", err) | ||||
| 		return | ||||
| 	} | ||||
| 	for _, column := range columns { | ||||
| 		column.NumIssues = int64(len(issuesMap[column.ID])) | ||||
| 	} | ||||
|  | ||||
| 	if project.CardType != project_model.CardTypeTextOnly { | ||||
| 		issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) | ||||
|   | ||||
| @@ -92,6 +92,11 @@ func Projects(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil { | ||||
| 		ctx.ServerError("LoadIssueNumbersForProjects", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	for i := range projects { | ||||
| 		rctx := renderhelper.NewRenderContextRepoComment(ctx, repo) | ||||
| 		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 | ||||
|  | ||||
| 	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, | ||||
| 		AssigneeID: optional.Some(assigneeID), | ||||
| 	}) | ||||
| @@ -320,6 +326,9 @@ func ViewProject(ctx *context.Context) { | ||||
| 		ctx.ServerError("LoadIssuesOfColumns", err) | ||||
| 		return | ||||
| 	} | ||||
| 	for _, column := range columns { | ||||
| 		column.NumIssues = int64(len(issuesMap[column.ID])) | ||||
| 	} | ||||
|  | ||||
| 	if project.CardType != project_model.CardTypeTextOnly { | ||||
| 		issuesAttachmentMap := make(map[int64][]*repo_model.Attachment) | ||||
|   | ||||
| @@ -419,7 +419,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { | ||||
| 		IsPull:     optional.Some(isPullList), | ||||
| 		SortType:   sortType, | ||||
| 		IsArchived: optional.Some(false), | ||||
| 		User:       ctx.Doer, | ||||
| 		Doer:       ctx.Doer, | ||||
| 	} | ||||
| 	// -------------------------------------------------------------------------- | ||||
| 	// 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. | ||||
| 	if ctx.Org != nil && ctx.Org.Organization != nil { | ||||
| 		opts.Org = ctx.Org.Organization | ||||
| 		opts.Owner = ctx.Org.Organization.AsUser() | ||||
| 		opts.Team = ctx.Org.Team | ||||
|  | ||||
| 		issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser()) | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import ( | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	project_model "code.gitea.io/gitea/models/project" | ||||
| 	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 | ||||
| @@ -84,3 +85,123 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum | ||||
| 		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="flex-text-block"> | ||||
| 						{{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 class="flex-text-block"> | ||||
| 						{{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> | ||||
| 				{{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-header{{if $canWriteProject}} tw-cursor-grab{{end}}"> | ||||
| 					<div class="ui circular label project-column-issue-count"> | ||||
| 						{{.NumIssues ctx}} | ||||
| 						{{.NumIssues}} | ||||
| 					</div> | ||||
| 					<div class="project-column-title-label gt-ellipsis">{{.Title}}</div> | ||||
| 					{{if $canWriteProject}} | ||||
|   | ||||
| @@ -78,7 +78,7 @@ func TestMoveRepoProjectColumns(t *testing.T) { | ||||
|  | ||||
| 	columnsAfter, err := project1.GetColumns(db.DefaultContext) | ||||
| 	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[2].ID, columnsAfter[1].ID) | ||||
| 	assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user