初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
project_model "gitea.dev/models/project"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
|
||||
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issueIDs := make([]int64, 0, len(sortedIssueIDs))
|
||||
for _, issueID := range sortedIssueIDs {
|
||||
issueIDs = append(issueIDs, issueID)
|
||||
}
|
||||
count, err := db.GetEngine(ctx).
|
||||
Where("project_id=?", column.ProjectID).
|
||||
In("issue_id", issueIDs).
|
||||
Count(new(project_model.ProjectIssue))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int(count) != len(sortedIssueIDs) {
|
||||
return errors.New("all issues have to be added to a project first")
|
||||
}
|
||||
|
||||
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := issues.LoadRepositories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
project, err := project_model.GetProjectByID(ctx, column.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuesMap := make(map[int64]*issues_model.Issue, len(issues))
|
||||
for _, issue := range issues {
|
||||
issuesMap[issue.ID] = issue
|
||||
}
|
||||
|
||||
for sorting, issueID := range sortedIssueIDs {
|
||||
curIssue := issuesMap[issueID]
|
||||
if curIssue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectColumnID := projectColumnMap[column.ProjectID]
|
||||
|
||||
if projectColumnID != column.ID {
|
||||
// add timeline to issue
|
||||
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeProjectColumn,
|
||||
Doer: doer,
|
||||
Repo: curIssue.Repo,
|
||||
Issue: curIssue,
|
||||
ProjectID: column.ProjectID,
|
||||
ProjectTitle: project.Title,
|
||||
ProjectColumnID: column.ID,
|
||||
ProjectColumnTitle: column.Title,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the column and sorting for this specific issue in this specific project.
|
||||
// IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure
|
||||
// that moving an issue's column in one project doesn't affect its column in other
|
||||
// projects when the issue is assigned to multiple projects.
|
||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
||||
Update(map[string]any{
|
||||
"project_board_id": column.ID,
|
||||
"sorting": sorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issues_model.IssueList) ([]*user_model.User, error) {
|
||||
var issueList issues_model.IssueList
|
||||
for _, colIssues := range issuesMap {
|
||||
issueList = append(issueList, colIssues...)
|
||||
}
|
||||
err := issueList.LoadAssignees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]*user_model.User, 0, len(issueList))
|
||||
usersAdded := container.Set[int64]{}
|
||||
for _, issue := range issueList {
|
||||
for _, assignee := range issue.Assignees {
|
||||
if !usersAdded.Contains(assignee.ID) {
|
||||
usersAdded.Add(assignee.ID)
|
||||
users = append(users, assignee)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(users, func(a, b *user_model.User) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return users, 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) (results map[int64]issues_model.IssueList, _ error) {
|
||||
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
|
||||
o.ProjectIDs = []int64{project.ID}
|
||||
o.SortType = "project-column-sorting"
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(issueList) == 0 {
|
||||
// if no issue, return directly, then no need to create a default column for an empty project
|
||||
return results, nil
|
||||
}
|
||||
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{
|
||||
ProjectIDs: []int64{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,215 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
org_model "gitea.dev/models/organization"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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(t.Context(), &pi1)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_, err = db.DeleteByID[project_model.ProjectIssue](t.Context(), pi1.ID)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pi2 := project_model.ProjectIssue{
|
||||
ProjectID: 4,
|
||||
IssueID: 4,
|
||||
ProjectColumnID: 4,
|
||||
}
|
||||
err = db.Insert(t.Context(), &pi2)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
_, err = db.DeleteByID[project_model.ProjectIssue](t.Context(), pi2.ID)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
projects, err := db.Find[project_model.Project](t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), &project1)
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
err := project_model.DeleteProjectByID(t.Context(), project1.ID)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// Get the default column created by the template (issues will be assigned here)
|
||||
defaultColumn, err := project1.MustDefaultColumn(t.Context())
|
||||
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(t.Context(), issue6, user2, []int64{project1.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(t.Context(), issue16, user2, []int64{project1.ID})
|
||||
assert.NoError(t, err)
|
||||
|
||||
projects, err := db.Find[project_model.Project](t.Context(), project_model.SearchOptions{
|
||||
OwnerID: org3.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, projects, 1)
|
||||
assert.Equal(t, project1.ID, projects[0].ID)
|
||||
|
||||
t.Run("Authenticated user", func(t *testing.T) {
|
||||
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
|
||||
Owner: org3.AsUser(),
|
||||
Doer: userAdmin,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1) // default column has 2 issues
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 2) // admin can visit both issues, one from public repository one from private repository
|
||||
})
|
||||
|
||||
t.Run("Anonymous user", func(t *testing.T) {
|
||||
columnIssues, err := LoadIssuesFromProject(t.Context(), projects[0], &issues_model.IssuesOptions{
|
||||
AllPublic: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[defaultColumn.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(t.Context(), projects[0], &issues_model.IssuesOptions{
|
||||
Owner: org3.AsUser(),
|
||||
Doer: user2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user2 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](t.Context(), 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(t.Context(), 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(t.Context(), 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(t.Context(), 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)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("LoadIssuesAssigneesForProject", func(t *testing.T) {
|
||||
issuesMap := map[int64]issues_model.IssueList{}
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
||||
issuesMap[1] = issues_model.IssueList{issue1}
|
||||
issuesMap[2] = issues_model.IssueList{issue6}
|
||||
assignees, err := LoadIssuesAssigneesForProject(t.Context(), issuesMap)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignees, 3)
|
||||
require.Equal(t, "user1", assignees[0].Name)
|
||||
require.Equal(t, "user10", assignees[1].Name)
|
||||
require.Equal(t, "user2", assignees[2].Name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package project
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/activities"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
Reference in New Issue
Block a user