Fix project issues list and counting (#33594) (#33619)

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>
pull/33632/head
Giteabot 6 days ago committed by GitHub
parent 99545ae2fd
commit 0512b02b01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 40
      models/issues/issue_project.go
  2. 22
      models/issues/issue_search.go
  3. 49
      models/project/column.go
  4. 8
      models/project/column_test.go
  5. 43
      models/project/issue.go
  6. 3
      models/project/project.go
  7. 4
      modules/indexer/issues/db/options.go
  8. 20
      routers/web/org/projects.go
  9. 11
      routers/web/repo/projects.go
  10. 4
      routers/web/user/home.go
  11. 121
      services/projects/issue.go
  12. 210
      services/projects/issue_test.go
  13. 18
      services/projects/main_test.go
  14. 4
      templates/projects/list.tmpl
  15. 2
      templates/projects/view.tmpl
  16. 2
      tests/integration/project_test.go

@ -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
Owner *user_model.User // issues permission scope, it could be an organization or a user
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.
@ -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
}

@ -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)
})
})
}

@ -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)}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
{{ctx.Locale.PrettyNumber .NumOpenIssues}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</div>
<div class="flex-text-block">
{{svg "octicon-check" 14}}
{{ctx.Locale.PrettyNumber (.NumClosedIssues ctx)}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
{{ctx.Locale.PrettyNumber .NumClosedIssues}}&nbsp;{{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)

Loading…
Cancel
Save