mirror of
https://github.com/go-gitea/gitea.git
synced 2025-02-20 11:43:57 +08: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:
parent
99545ae2fd
commit
0512b02b01
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user