aboutsummaryrefslogtreecommitdiffstats
path: root/services/projects
diff options
context:
space:
mode:
authorLunny Xiao <xiaolunwen@gmail.com>2025-02-16 21:14:56 -0800
committerGitHub <noreply@github.com>2025-02-17 05:14:56 +0000
commit69de5a65c25b08b501ed1e8123fcdad43f382213 (patch)
tree9b410f46f8249acca09df5fda51ca4e219742c7c /services/projects
parent5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a (diff)
downloadgitea-69de5a65c25b08b501ed1e8123fcdad43f382213.tar.gz
gitea-69de5a65c25b08b501ed1e8123fcdad43f382213.zip
Fix project issues list and counting (#33594)
Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Diffstat (limited to 'services/projects')
-rw-r--r--services/projects/issue.go121
-rw-r--r--services/projects/issue_test.go210
-rw-r--r--services/projects/main_test.go17
3 files changed, 348 insertions, 0 deletions
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..d39c82a140
--- /dev/null
+++ b/services/projects/main_test.go
@@ -0,0 +1,17 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}