初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+249
View File
@@ -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
}
+215
View File
@@ -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)
})
}
+17
View File
@@ -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)
}