diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2025-02-16 21:14:56 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-02-17 05:14:56 +0000 |
commit | 69de5a65c25b08b501ed1e8123fcdad43f382213 (patch) | |
tree | 9b410f46f8249acca09df5fda51ca4e219742c7c /services/projects | |
parent | 5df9fd3e9c6ae7f848da65dbe9b9d321f29c003a (diff) | |
download | gitea-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.go | 121 | ||||
-rw-r--r-- | services/projects/issue_test.go | 210 | ||||
-rw-r--r-- | services/projects/main_test.go | 17 |
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) +} |