From 0512b02b01e3b979f5dcf302d2c75bc16a62f973 Mon Sep 17 00:00:00 2001
From: Giteabot <teabot@gitea.io>
Date: Tue, 18 Feb 2025 00:59:32 +0800
Subject: [PATCH] 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>
---
 models/issues/issue_project.go       |  40 ++---
 models/issues/issue_search.go        |  24 +--
 models/project/column.go             |  49 ++++---
 models/project/column_test.go        |   8 +-
 models/project/issue.go              |  43 ------
 models/project/project.go            |   3 +
 modules/indexer/issues/db/options.go |   4 +-
 routers/web/org/projects.go          |  20 ++-
 routers/web/repo/projects.go         |  11 +-
 routers/web/user/home.go             |   4 +-
 services/projects/issue.go           | 121 +++++++++++++++
 services/projects/issue_test.go      | 210 +++++++++++++++++++++++++++
 services/projects/main_test.go       |  18 +++
 templates/projects/list.tmpl         |   4 +-
 templates/projects/view.tmpl         |   2 +-
 tests/integration/project_test.go    |   2 +-
 16 files changed, 459 insertions(+), 104 deletions(-)
 create mode 100644 services/projects/issue_test.go
 create mode 100644 services/projects/main_test.go

diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
index f520604321..0185244783 100644
--- a/models/issues/issue_project.go
+++ b/models/issues/issue_project.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
 				}
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index f1cd125d49..694b918755 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -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
 				),
 			)
 		}
diff --git a/models/project/column.go b/models/project/column.go
index 222f448599..5f581b5880 100644
--- a/models/project/column.go
+++ b/models/project/column.go
@@ -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{
diff --git a/models/project/column_test.go b/models/project/column_test.go
index 566667e45d..66db23a3e4 100644
--- a/models/project/column_test.go
+++ b/models/project/column_test.go
@@ -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))
diff --git a/models/project/issue.go b/models/project/issue.go
index b4347a9c2b..98eed2a213 100644
--- a/models/project/issue.go
+++ b/models/project/issue.go
@@ -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")
diff --git a/models/project/project.go b/models/project/project.go
index 7385efd39d..78cba8b574 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -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"`
diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go
index 42834f6e88..87ce398a20 100644
--- a/modules/indexer/issues/db/options.go
+++ b/modules/indexer/issues/db/options.go
@@ -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 {
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index ebfbc22db1..8eeb67a1ac 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -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)
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index d07ff4bbbe..4c11ffd407 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -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)
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index df9a8a6bf6..c14bb2f28d 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -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())
diff --git a/services/projects/issue.go b/services/projects/issue.go
index 6ca0f16806..090d19d2f4 100644
--- a/services/projects/issue.go
+++ b/services/projects/issue.go
@@ -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
+}
diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go
new file mode 100644
index 0000000000..b6f0b1dae1
--- /dev/null
+++ b/services/projects/issue_test.go
@@ -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)
+		})
+	})
+}
diff --git a/services/projects/main_test.go b/services/projects/main_test.go
new file mode 100644
index 0000000000..93e4887b55
--- /dev/null
+++ b/services/projects/main_test.go
@@ -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)
+}
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index f5a48f7241..31a31527a9 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -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)}}
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
index 966d3bf604..82d744bbc2 100644
--- a/templates/projects/view.tmpl
+++ b/templates/projects/view.tmpl
@@ -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}}
diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go
index cdff9aa2fd..111356b1da 100644
--- a/tests/integration/project_test.go
+++ b/tests/integration/project_test.go
@@ -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)