mirror of
https://github.com/go-gitea/gitea
synced 2025-02-24 22:04:18 +00:00
Fix project issues list and counting (#33594)
Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
5df9fd3e9c
commit
69de5a65c2
@ -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 {
|
||||
|
@ -77,6 +77,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),
|
||||
@ -327,6 +332,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 {
|
||||
@ -340,14 +349,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)
|
||||
|
@ -417,7 +417,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.
|
||||
@ -429,7 +429,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)
|
||||
})
|
||||
})
|
||||
}
|
17
services/projects/main_test.go
Normal file
17
services/projects/main_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
// 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/actions"
|
||||
_ "code.gitea.io/gitea/models/activities"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
@ -54,11 +54,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)}}
|
||||
|
@ -70,7 +70,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-text 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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user