mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-06 10:39:59 +02:00
Allow multiple projects per issue and pull requests (#36784)
Add ability to add and remove multiple projects per issue and pull request. Resolve #12974 --------- Signed-off-by: Icy Avocado <avocado@ovacoda.com> Co-authored-by: Tyrone Yeh <siryeh@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: OpenCode (gpt-5.2-codex) <opencode@openai.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
parent
52d6baf5a8
commit
81692ceafa
1
.gitignore
vendored
1
.gitignore
vendored
@ -65,6 +65,7 @@ cpu.out
|
||||
/indexers
|
||||
/log
|
||||
/public/assets/img/avatar
|
||||
/tests/e2e-output
|
||||
/tests/integration/gitea-integration-*
|
||||
/tests/integration/indexers-*
|
||||
/tests/*.ini
|
||||
|
||||
2
Makefile
2
Makefile
@ -478,7 +478,7 @@ playwright: deps-frontend
|
||||
@pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium firefox $(PLAYWRIGHT_FLAGS)
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e: playwright backend
|
||||
test-e2e: playwright frontend backend
|
||||
@EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS)
|
||||
|
||||
.PHONY: build
|
||||
|
||||
@ -59,17 +59,18 @@ type Issue struct {
|
||||
PosterID int64 `xorm:"INDEX"`
|
||||
Poster *user_model.User `xorm:"-"`
|
||||
OriginalAuthor string
|
||||
OriginalAuthorID int64 `xorm:"index"`
|
||||
Title string `xorm:"name"`
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
RenderedContent template.HTML `xorm:"-"`
|
||||
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Labels []*Label `xorm:"-"`
|
||||
isLabelsLoaded bool `xorm:"-"`
|
||||
MilestoneID int64 `xorm:"INDEX"`
|
||||
Milestone *Milestone `xorm:"-"`
|
||||
isMilestoneLoaded bool `xorm:"-"`
|
||||
Project *project_model.Project `xorm:"-"`
|
||||
OriginalAuthorID int64 `xorm:"index"`
|
||||
Title string `xorm:"name"`
|
||||
Content string `xorm:"LONGTEXT"`
|
||||
RenderedContent template.HTML `xorm:"-"`
|
||||
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
||||
Labels []*Label `xorm:"-"`
|
||||
isLabelsLoaded bool `xorm:"-"`
|
||||
MilestoneID int64 `xorm:"INDEX"`
|
||||
Milestone *Milestone `xorm:"-"`
|
||||
isMilestoneLoaded bool `xorm:"-"`
|
||||
Projects []*project_model.Project `xorm:"-"`
|
||||
isProjectsLoaded bool `xorm:"-"`
|
||||
Priority int
|
||||
AssigneeID int64 `xorm:"-"`
|
||||
Assignee *user_model.User `xorm:"-"`
|
||||
@ -305,7 +306,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = issue.LoadProject(ctx); err != nil {
|
||||
if err = issue.LoadProjects(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -355,6 +356,7 @@ func (issue *Issue) ResetAttributesLoaded() {
|
||||
issue.isMilestoneLoaded = false
|
||||
issue.isAttachmentsLoaded = false
|
||||
issue.isAssigneeLoaded = false
|
||||
issue.isProjectsLoaded = false
|
||||
}
|
||||
|
||||
// GetIsRead load the `IsRead` field of the issue
|
||||
|
||||
@ -185,7 +185,7 @@ func (issues IssueList) LoadMilestones(ctx context.Context) error {
|
||||
|
||||
func (issues IssueList) LoadProjects(ctx context.Context) error {
|
||||
issueIDs := issues.getIssueIDs()
|
||||
projectMaps := make(map[int64]*project_model.Project, len(issues))
|
||||
issueProjectMaps := make(map[int64][]*project_model.Project, len(issues))
|
||||
left := len(issueIDs)
|
||||
|
||||
type projectWithIssueID struct {
|
||||
@ -202,19 +202,21 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
|
||||
Select("project.*, project_issue.issue_id").
|
||||
Join("INNER", "project_issue", "project.id = project_issue.project_id").
|
||||
In("project_issue.issue_id", issueIDs[:limit]).
|
||||
OrderBy("project_issue.issue_id ASC, project.id ASC").
|
||||
Find(&projects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, project := range projects {
|
||||
projectMaps[project.IssueID] = project.Project
|
||||
issueProjectMaps[project.IssueID] = append(issueProjectMaps[project.IssueID], project.Project)
|
||||
}
|
||||
left -= limit
|
||||
issueIDs = issueIDs[limit:]
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
issue.Project = projectMaps[issue.ID]
|
||||
issue.Projects = issueProjectMaps[issue.ID]
|
||||
issue.isProjectsLoaded = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -65,10 +65,10 @@ func TestIssueList_LoadAttributes(t *testing.T) {
|
||||
}
|
||||
if issue.ID == int64(1) {
|
||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||
assert.NotNil(t, issue.Project)
|
||||
assert.Equal(t, int64(1), issue.Project.ID)
|
||||
assert.NotEmpty(t, issue.Projects)
|
||||
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||
} else {
|
||||
assert.Nil(t, issue.Project)
|
||||
assert.Empty(t, issue.Projects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,41 +12,38 @@ import (
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
// LoadProject load the project the issue was assigned to
|
||||
func (issue *Issue) LoadProject(ctx context.Context) (err error) {
|
||||
if issue.Project == nil {
|
||||
var p project_model.Project
|
||||
has, err := db.GetEngine(ctx).Table("project").
|
||||
// LoadProjects loads all projects the issue is assigned to
|
||||
func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
|
||||
if !issue.isProjectsLoaded {
|
||||
err = db.GetEngine(ctx).Table("project").
|
||||
Join("INNER", "project_issue", "project.id=project_issue.project_id").
|
||||
Where("project_issue.issue_id = ?", issue.ID).Get(&p)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if has {
|
||||
issue.Project = &p
|
||||
Where("project_issue.issue_id = ?", issue.ID).
|
||||
OrderBy("project.id ASC").
|
||||
Find(&issue.Projects)
|
||||
if err == nil {
|
||||
issue.isProjectsLoaded = true
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (issue *Issue) projectID(ctx context.Context) int64 {
|
||||
var ip project_model.ProjectIssue
|
||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
||||
if err != nil || !has {
|
||||
return 0
|
||||
}
|
||||
return ip.ProjectID
|
||||
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
|
||||
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
|
||||
return projectIDs, err
|
||||
}
|
||||
|
||||
// ProjectColumnID return project column id if issue was assigned to one
|
||||
func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
|
||||
var ip project_model.ProjectIssue
|
||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if !has {
|
||||
return 0, nil
|
||||
// ProjectColumnMap returns a map of project ID to column ID for this issue.
|
||||
func (issue *Issue) ProjectColumnMap(ctx context.Context) (map[int64]int64, error) {
|
||||
var projIssues []project_model.ProjectIssue
|
||||
if err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Find(&projIssues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ip.ProjectColumnID, nil
|
||||
|
||||
result := make(map[int64]int64, len(projIssues))
|
||||
for _, projIssue := range projIssues {
|
||||
result[projIssue.ProjectID] = projIssue.ProjectColumnID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) {
|
||||
@ -64,66 +61,91 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i
|
||||
return result, 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 {
|
||||
// IssueAssignOrRemoveProject updates the projects associated with an issue.
|
||||
// It adds projects that are in newProjectIDs but not currently assigned,
|
||||
// and removes projects that are currently assigned but not in newProjectIDs.
|
||||
// If newProjectIDs is empty, all projects are removed from the issue.
|
||||
// When adding an issue to a project, it is placed in the project's default column.
|
||||
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
oldProjectID := issue.projectID(ctx)
|
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only check if we add a new project and not remove it.
|
||||
if newProjectID > 0 {
|
||||
newProject, err := project_model.GetProjectByID(ctx, newProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
|
||||
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
|
||||
}
|
||||
if newColumnID == 0 {
|
||||
newDefaultColumn, err := newProject.MustDefaultColumn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newColumnID = newDefaultColumn.ID
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldProjectID > 0 || newProjectID > 0 {
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: oldProjectID,
|
||||
ProjectID: newProjectID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if newProjectID == 0 {
|
||||
return nil
|
||||
}
|
||||
if newColumnID == 0 {
|
||||
panic("newColumnID must not be zero") // shouldn't happen
|
||||
}
|
||||
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID)
|
||||
oldProjectIDs, err := issue.projectIDs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
||||
IssueID: issue.ID,
|
||||
ProjectID: newProjectID,
|
||||
ProjectColumnID: newColumnID,
|
||||
Sorting: newSorting,
|
||||
})
|
||||
|
||||
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)
|
||||
issue.isProjectsLoaded = false
|
||||
issue.Projects = nil
|
||||
|
||||
if len(projectsToRemove) > 0 {
|
||||
if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, projectID := range projectsToRemove {
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: projectID,
|
||||
ProjectID: 0,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(projectsToAdd) > 0 {
|
||||
projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, projectID := range projectsToAdd {
|
||||
newProject, ok := projectMap[projectID]
|
||||
if !ok {
|
||||
return util.NewNotExistErrorf("project %d not found", projectID)
|
||||
}
|
||||
if !newProject.CanBeAccessedByOwnerRepo(issue.Repo.OwnerID, issue.Repo) {
|
||||
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
|
||||
}
|
||||
|
||||
defaultColumn, err := newProject.MustDefaultColumn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, projectID, defaultColumn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Insert(ctx, &project_model.ProjectIssue{
|
||||
IssueID: issue.ID,
|
||||
ProjectID: projectID,
|
||||
ProjectColumnID: defaultColumn.ID,
|
||||
Sorting: newSorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeProject,
|
||||
Doer: doer,
|
||||
Repo: issue.Repo,
|
||||
Issue: issue,
|
||||
OldProjectID: 0,
|
||||
ProjectID: projectID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
149
models/issues/issue_project_multi_test.go
Normal file
149
models/issues/issue_project_multi_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueMultipleProjects(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("GeneralTest", func(t *testing.T) {
|
||||
// Get test data
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
project1 := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
|
||||
|
||||
// Create a second project for the same repository
|
||||
project2 := &project_model.Project{
|
||||
Title: "Test Project 2",
|
||||
RepoID: issue1.RepoID,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeBasicKanban,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), project2))
|
||||
defer func() {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), project2.ID)
|
||||
}()
|
||||
|
||||
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, issue1.Projects)
|
||||
|
||||
// assign issue to both projects (each project uses its own default column)
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 2)
|
||||
assert.ElementsMatch(t, []int64{project1.ID, project2.ID}, []int64{issue1.Projects[0].ID, issue1.Projects[1].ID}, "Issue should be in both projects")
|
||||
|
||||
// test issue's project column map
|
||||
projectColumnMap, err := issue1.ProjectColumnMap(t.Context())
|
||||
p1Col, _ := project1.MustDefaultColumn(t.Context())
|
||||
p2Col, _ := project2.MustDefaultColumn(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, p1Col.ID, projectColumnMap[project1.ID])
|
||||
assert.Equal(t, p2Col.ID, projectColumnMap[project2.ID])
|
||||
|
||||
// only keep project2
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
|
||||
|
||||
// also test ResetAttributesLoaded
|
||||
issue1.Projects = nil
|
||||
issue1.ResetAttributesLoaded()
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, issue1.Projects, 1)
|
||||
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
|
||||
|
||||
// remove issue's projects
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issue1.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, issue1.Projects)
|
||||
})
|
||||
|
||||
t.Run("QueryByMultipleProjectIDs", func(t *testing.T) {
|
||||
// Get test data
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// Create three projects
|
||||
var projects []*project_model.Project
|
||||
for i := 1; i <= 3; i++ {
|
||||
project := &project_model.Project{
|
||||
Title: fmt.Sprintf("Query Test Project %d", i),
|
||||
RepoID: issue1.RepoID,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeBasicKanban,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), project))
|
||||
projects = append(projects, project)
|
||||
defer func(id int64) {
|
||||
_ = project_model.DeleteProjectByID(t.Context(), id)
|
||||
}(project.ID)
|
||||
}
|
||||
|
||||
// Assign issue1 to projects 1 and 2
|
||||
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assign issue2 to project 3
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Query for issues in project 3 only (should find issue2)
|
||||
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
|
||||
RepoIDs: []int64{issue1.RepoID},
|
||||
ProjectIDs: []int64{projects[2].ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, issues, "Should find issues in project 3")
|
||||
|
||||
// Verify issue2 is in the results
|
||||
foundIssue2 := false
|
||||
for _, issue := range issues {
|
||||
if issue.ID == issue2.ID {
|
||||
foundIssue2 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundIssue2, "Issue 2 should be found when querying project 3")
|
||||
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. It should use "AND" but not "OR".
|
||||
// Clean up
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@ -16,6 +16,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
@ -36,8 +37,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
||||
ReviewedID int64
|
||||
SubscriberID int64
|
||||
MilestoneIDs []int64
|
||||
ProjectID int64
|
||||
ProjectColumnID int64
|
||||
ProjectIDs []int64
|
||||
IsClosed optional.Option[bool]
|
||||
IsPull optional.Option[bool]
|
||||
LabelIDs []int64
|
||||
@ -198,26 +198,19 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
}
|
||||
|
||||
func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if opts.ProjectID > 0 { // specific project
|
||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
|
||||
And("project_issue.project_id=?", opts.ProjectID)
|
||||
} else if opts.ProjectID == db.NoConditionID { // show those that are in no project
|
||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0})))
|
||||
projectIDs := util.SliceRemoveAll(opts.ProjectIDs, 0)
|
||||
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
|
||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
|
||||
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
|
||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0])
|
||||
} else if len(projectIDs) > 1 { // multiple projects
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs))))
|
||||
}
|
||||
// opts.ProjectID == 0 means all projects,
|
||||
// empty projectIDs means all projects,
|
||||
// do not need to apply any condition
|
||||
}
|
||||
|
||||
func applyProjectColumnCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||
// opts.ProjectColumnID == 0 means all project columns,
|
||||
// do not need to apply any condition
|
||||
if opts.ProjectColumnID > 0 {
|
||||
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectColumnID}))
|
||||
} else if opts.ProjectColumnID == db.NoConditionID {
|
||||
sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
|
||||
}
|
||||
}
|
||||
|
||||
func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
if len(opts.RepoIDs) == 1 {
|
||||
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
|
||||
@ -276,8 +269,6 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||
|
||||
applyProjectCondition(sess, opts)
|
||||
|
||||
applyProjectColumnCondition(sess, opts)
|
||||
|
||||
if opts.IsPull.Has() {
|
||||
sess.And("issue.is_pull=?", opts.IsPull.Value())
|
||||
}
|
||||
|
||||
@ -424,10 +424,10 @@ func TestIssueLoadAttributes(t *testing.T) {
|
||||
}
|
||||
if issue.ID == int64(1) {
|
||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||
assert.NotNil(t, issue.Project)
|
||||
assert.Equal(t, int64(1), issue.Project.ID)
|
||||
assert.NotEmpty(t, issue.Projects)
|
||||
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||
} else {
|
||||
assert.Nil(t, issue.Project)
|
||||
assert.Empty(t, issue.Projects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -302,6 +302,15 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProjectsMapByIDs returns projects by a list of IDs.
|
||||
func GetProjectsMapByIDs(ctx context.Context, ids []int64) (map[int64]*Project, error) {
|
||||
projects := make(map[int64]*Project, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return projects, nil
|
||||
}
|
||||
return projects, db.GetEngine(ctx).In("id", ids).Find(&projects)
|
||||
}
|
||||
|
||||
func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) {
|
||||
p := new(Project)
|
||||
has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p)
|
||||
|
||||
@ -27,7 +27,7 @@ import (
|
||||
const (
|
||||
issueIndexerAnalyzer = "issueIndexer"
|
||||
issueIndexerDocType = "issueIndexerDocType"
|
||||
issueIndexerLatestVersion = 5
|
||||
issueIndexerLatestVersion = 6
|
||||
)
|
||||
|
||||
const unicodeNormalizeName = "unicodeNormalize"
|
||||
@ -83,8 +83,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
|
||||
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("no_project", boolFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
|
||||
docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping)
|
||||
@ -241,11 +241,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
|
||||
}
|
||||
|
||||
if options.ProjectID.Has() {
|
||||
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
|
||||
}
|
||||
if options.ProjectColumnID.Has() {
|
||||
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
|
||||
if options.NoProjectOnly {
|
||||
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project"))
|
||||
} else if len(options.ProjectIDs) > 0 {
|
||||
var projectQueries []query.Query
|
||||
for _, projectID := range options.ProjectIDs {
|
||||
projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids"))
|
||||
}
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...))
|
||||
}
|
||||
|
||||
if options.PosterID != "" {
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/indexer/issues/internal"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
|
||||
@ -65,8 +66,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m
|
||||
ReviewRequestedID: convertID(options.ReviewRequestedID),
|
||||
ReviewedID: convertID(options.ReviewedID),
|
||||
SubscriberID: convertID(options.SubscriberID),
|
||||
ProjectID: convertID(options.ProjectID),
|
||||
ProjectColumnID: convertID(options.ProjectColumnID),
|
||||
ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs),
|
||||
IsClosed: options.IsClosed,
|
||||
IsPull: options.IsPull,
|
||||
IncludedLabelNames: nil,
|
||||
|
||||
@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
||||
searchOpt.MilestoneIDs = opts.MilestoneIDs
|
||||
}
|
||||
|
||||
if opts.ProjectID > 0 {
|
||||
searchOpt.ProjectID = optional.Some(opts.ProjectID)
|
||||
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
|
||||
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
|
||||
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
|
||||
searchOpt.NoProjectOnly = true
|
||||
} else {
|
||||
searchOpt.ProjectIDs = opts.ProjectIDs
|
||||
}
|
||||
|
||||
searchOpt.AssigneeID = opts.AssigneeID
|
||||
@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
||||
return nil
|
||||
}
|
||||
|
||||
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
|
||||
searchOpt.PosterID = opts.PosterID
|
||||
searchOpt.MentionID = convertID(opts.MentionedID)
|
||||
searchOpt.ReviewedID = convertID(opts.ReviewedID)
|
||||
|
||||
@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
issueIndexerLatestVersion = 2
|
||||
issueIndexerLatestVersion = 3
|
||||
// multi-match-types, currently only 2 types are used
|
||||
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
|
||||
esMultiMatchTypeBestFields = "best_fields"
|
||||
@ -68,8 +68,8 @@ const (
|
||||
"label_ids": { "type": "integer", "index": true },
|
||||
"no_label": { "type": "boolean", "index": true },
|
||||
"milestone_id": { "type": "integer", "index": true },
|
||||
"project_id": { "type": "integer", "index": true },
|
||||
"project_board_id": { "type": "integer", "index": true },
|
||||
"project_ids": { "type": "integer", "index": true },
|
||||
"no_project": { "type": "boolean", "index": true },
|
||||
"poster_id": { "type": "integer", "index": true },
|
||||
"assignee_id": { "type": "integer", "index": true },
|
||||
"mention_ids": { "type": "integer", "index": true },
|
||||
@ -204,11 +204,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
|
||||
}
|
||||
|
||||
if options.ProjectID.Has() {
|
||||
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
|
||||
}
|
||||
if options.ProjectColumnID.Has() {
|
||||
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
|
||||
if options.NoProjectOnly {
|
||||
query.Must(elastic.NewTermQuery("no_project", true))
|
||||
} else if len(options.ProjectIDs) > 0 {
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...))
|
||||
}
|
||||
|
||||
if options.PosterID != "" {
|
||||
|
||||
@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
SearchOptions{
|
||||
ProjectID: optional.Some(int64(1)),
|
||||
ProjectIDs: []int64{1},
|
||||
},
|
||||
[]int64{5, 3, 2, 1},
|
||||
},
|
||||
{
|
||||
SearchOptions{
|
||||
ProjectColumnID: optional.Some(int64(1)),
|
||||
},
|
||||
[]int64{1},
|
||||
},
|
||||
{
|
||||
SearchOptions{
|
||||
ProjectColumnID: optional.Some(int64(0)), // issue with in default column
|
||||
},
|
||||
[]int64{2},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expectedIDs, issueIDs)
|
||||
}
|
||||
|
||||
// Test filtering for issues with no project assigned using dynamic validation
|
||||
t.Run("no project assigned", func(t *testing.T) {
|
||||
issueIDs, total, err := SearchIssues(t.Context(), &SearchOptions{
|
||||
ProjectIDs: []int64{db.NoConditionID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, issueIDs)
|
||||
assert.Equal(t, total, int64(len(issueIDs)))
|
||||
|
||||
// Verify each returned issue actually has no project
|
||||
for _, issueID := range issueIDs {
|
||||
issue, err := issues.GetIssueByID(t.Context(), issueID)
|
||||
require.NoError(t, err)
|
||||
err = issue.LoadProjects(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, issue.Projects, "Issue %d should have no projects", issueID)
|
||||
}
|
||||
|
||||
// Count total issues with no project to verify we got them all
|
||||
allIssues, err := issues.Issues(t.Context(), &issues.IssuesOptions{
|
||||
ProjectIDs: []int64{db.NoConditionID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, issueIDs, len(allIssues), "Should return all issues with no project")
|
||||
})
|
||||
}
|
||||
|
||||
func searchIssueWithPaginator(t *testing.T) {
|
||||
|
||||
@ -30,8 +30,9 @@ type IndexerData struct {
|
||||
LabelIDs []int64 `json:"label_ids"`
|
||||
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
|
||||
MilestoneID int64 `json:"milestone_id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
|
||||
ProjectIDs []int64 `json:"project_ids"`
|
||||
NoProject bool `json:"no_project"` // True if ProjectIDs is empty
|
||||
ProjectColumnMap map[int64]int64 `json:"project_column_map,omitempty"` // Maps project ID to column ID for each project the issue is in
|
||||
PosterID int64 `json:"poster_id"`
|
||||
AssigneeID int64 `json:"assignee_id"`
|
||||
MentionIDs []int64 `json:"mention_ids"`
|
||||
@ -94,8 +95,8 @@ type SearchOptions struct {
|
||||
|
||||
MilestoneIDs []int64 // milestones the issues have
|
||||
|
||||
ProjectID optional.Option[int64] // project the issues belong to
|
||||
ProjectColumnID optional.Option[int64] // project column the issues belong to
|
||||
ProjectIDs []int64 // project the issues belong to. FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong.
|
||||
NoProjectOnly bool // if the issues have no project, if true, ProjectIDs will be ignored
|
||||
|
||||
PosterID string // poster of the issues, "(none)" or "(any)" or a user ID
|
||||
AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID
|
||||
|
||||
@ -301,75 +301,41 @@ var cases = []*testIndexerCase{
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ProjectID",
|
||||
Name: "ProjectIDs",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
},
|
||||
ProjectID: optional.Some(int64(1)),
|
||||
ProjectIDs: []int64{1},
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
for _, v := range result.Hits {
|
||||
assert.Equal(t, int64(1), data[v.ID].ProjectID)
|
||||
assert.Contains(t, data[v.ID].ProjectIDs, int64(1))
|
||||
}
|
||||
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||
return v.ProjectID == 1
|
||||
return slices.Contains(v.ProjectIDs, int64(1))
|
||||
}), result.Total)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "no ProjectID",
|
||||
Name: "no ProjectIDs (empty array)",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
PageSize: 50,
|
||||
},
|
||||
ProjectID: optional.Some(int64(0)),
|
||||
NoProjectOnly: true,
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
// Verify only issues with no projects are returned
|
||||
for _, v := range result.Hits {
|
||||
assert.Equal(t, int64(0), data[v.ID].ProjectID)
|
||||
assert.Empty(t, data[v.ID].ProjectIDs, "Issue %d should have no projects", v.ID)
|
||||
}
|
||||
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||
return v.ProjectID == 0
|
||||
}), result.Total)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ProjectColumnID",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
},
|
||||
ProjectColumnID: optional.Some(int64(1)),
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
for _, v := range result.Hits {
|
||||
assert.Equal(t, int64(1), data[v.ID].ProjectColumnID)
|
||||
}
|
||||
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||
return v.ProjectColumnID == 1
|
||||
}), result.Total)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "no ProjectColumnID",
|
||||
SearchOptions: &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{
|
||||
PageSize: 5,
|
||||
},
|
||||
ProjectColumnID: optional.Some(int64(0)),
|
||||
},
|
||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||
assert.Len(t, result.Hits, 5)
|
||||
for _, v := range result.Hits {
|
||||
assert.Equal(t, int64(0), data[v.ID].ProjectColumnID)
|
||||
}
|
||||
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||
return v.ProjectColumnID == 0
|
||||
}), result.Total)
|
||||
// Verify we got ALL issues with no projects
|
||||
expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||
return len(v.ProjectIDs) == 0
|
||||
})
|
||||
assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount)
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -706,6 +672,10 @@ func generateDefaultIndexerData() []*internal.IndexerData {
|
||||
for i := range subscriberIDs {
|
||||
subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
|
||||
}
|
||||
projectIDs := make([]int64, id%5)
|
||||
for i := range projectIDs {
|
||||
projectIDs[i] = int64(i) + 1 // projectID should not be 0
|
||||
}
|
||||
|
||||
data = append(data, &internal.IndexerData{
|
||||
ID: id,
|
||||
@ -719,8 +689,8 @@ func generateDefaultIndexerData() []*internal.IndexerData {
|
||||
LabelIDs: labelIDs,
|
||||
NoLabel: len(labelIDs) == 0,
|
||||
MilestoneID: issueIndex % 4,
|
||||
ProjectID: issueIndex % 5,
|
||||
ProjectColumnID: issueIndex % 6,
|
||||
ProjectIDs: projectIDs,
|
||||
NoProject: len(projectIDs) == 0,
|
||||
PosterID: id%10 + 1, // PosterID should not be 0
|
||||
AssigneeID: issueIndex % 10,
|
||||
MentionIDs: mentionIDs,
|
||||
|
||||
@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
issueIndexerLatestVersion = 4
|
||||
issueIndexerLatestVersion = 5
|
||||
|
||||
// TODO: make this configurable if necessary
|
||||
maxTotalHits = 10000
|
||||
@ -71,8 +71,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
|
||||
"label_ids",
|
||||
"no_label",
|
||||
"milestone_id",
|
||||
"project_id",
|
||||
"project_board_id",
|
||||
"project_ids",
|
||||
"no_project",
|
||||
"poster_id",
|
||||
"assignee_id",
|
||||
"mention_ids",
|
||||
@ -182,11 +182,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
||||
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
|
||||
}
|
||||
|
||||
if options.ProjectID.Has() {
|
||||
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
|
||||
}
|
||||
if options.ProjectColumnID.Has() {
|
||||
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
|
||||
if options.NoProjectOnly {
|
||||
query.And(inner_meilisearch.NewFilterEq("no_project", true))
|
||||
} else if len(options.ProjectIDs) > 0 {
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||
query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...))
|
||||
}
|
||||
|
||||
if options.PosterID != "" {
|
||||
|
||||
@ -87,14 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var projectID int64
|
||||
if issue.Project != nil {
|
||||
projectID = issue.Project.ID
|
||||
}
|
||||
|
||||
projectColumnID, err := issue.ProjectColumnID(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
projectIDs := make([]int64, 0, len(issue.Projects))
|
||||
for _, project := range issue.Projects {
|
||||
projectIDs = append(projectIDs, project.ID)
|
||||
}
|
||||
|
||||
if err := issue.Repo.LoadOwner(ctx); err != nil {
|
||||
@ -114,8 +109,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
||||
LabelIDs: labels,
|
||||
NoLabel: len(labels) == 0,
|
||||
MilestoneID: issue.MilestoneID,
|
||||
ProjectID: projectID,
|
||||
ProjectColumnID: projectColumnID,
|
||||
ProjectIDs: projectIDs,
|
||||
NoProject: len(projectIDs) == 0,
|
||||
PosterID: issue.PosterID,
|
||||
AssigneeID: issue.AssigneeID,
|
||||
MentionIDs: mentionIDs,
|
||||
|
||||
@ -60,6 +60,7 @@ type Issue struct {
|
||||
Attachments []*Attachment `json:"assets"`
|
||||
Labels []*Label `json:"labels"`
|
||||
Milestone *Milestone `json:"milestone"`
|
||||
Projects []*Project `json:"projects"`
|
||||
// deprecated
|
||||
Assignee *User `json:"assignee"`
|
||||
Assignees []*User `json:"assignees"`
|
||||
@ -100,7 +101,9 @@ type CreateIssueOption struct {
|
||||
Milestone int64 `json:"milestone"`
|
||||
// list of label ids
|
||||
Labels []int64 `json:"labels"`
|
||||
Closed bool `json:"closed"`
|
||||
// list of project ids
|
||||
Projects []int64 `json:"projects"`
|
||||
Closed bool `json:"closed"`
|
||||
}
|
||||
|
||||
// EditIssueOption options for editing an issue
|
||||
@ -112,7 +115,9 @@ type EditIssueOption struct {
|
||||
Assignee *string `json:"assignee"`
|
||||
Assignees []string `json:"assignees"`
|
||||
Milestone *int64 `json:"milestone"`
|
||||
State *string `json:"state"`
|
||||
// list of project ids to set (replaces existing projects)
|
||||
Projects *[]int64 `json:"projects"`
|
||||
State *string `json:"state"`
|
||||
// swagger:strfmt date-time
|
||||
Deadline *time.Time `json:"due_date"`
|
||||
RemoveDeadline *bool `json:"unset_due_date"`
|
||||
|
||||
33
modules/structs/project.go
Normal file
33
modules/structs/project.go
Normal file
@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project represents a project
|
||||
// swagger:model
|
||||
type Project struct {
|
||||
// ID is the unique identifier for the project
|
||||
ID int64 `json:"id"`
|
||||
// Title is the title of the project
|
||||
Title string `json:"title"`
|
||||
// Description provides details about the project
|
||||
Description string `json:"description"`
|
||||
// OwnerID is the owner of the project (for org-level projects)
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
// RepoID is the repository this project belongs to (for repo-level projects)
|
||||
RepoID int64 `json:"repo_id,omitempty"`
|
||||
// CreatorID is the user who created the project
|
||||
CreatorID int64 `json:"creator_id"`
|
||||
// IsClosed indicates if the project is closed
|
||||
IsClosed bool `json:"is_closed"`
|
||||
// swagger:strfmt date-time
|
||||
Created time.Time `json:"created_at"`
|
||||
// swagger:strfmt date-time
|
||||
Updated time.Time `json:"updated_at"`
|
||||
// swagger:strfmt date-time
|
||||
Closed *time.Time `json:"closed_at,omitempty"`
|
||||
}
|
||||
@ -5,6 +5,7 @@ package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -36,7 +37,11 @@ func (r *pageRenderer) funcMapDummy() template.FuncMap {
|
||||
}
|
||||
|
||||
func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor
|
||||
return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx))
|
||||
tmpls := r.tmplRenderer.Templates()
|
||||
if tmpls == nil {
|
||||
return nil, fmt.Errorf("no templates defined for %s", tmpl)
|
||||
}
|
||||
return tmpls.Executor(tmpl, r.funcMap(templateCtx))
|
||||
}
|
||||
|
||||
func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor
|
||||
|
||||
@ -6,6 +6,11 @@ package templates
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
type SliceUtils struct{}
|
||||
@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// JoinInt64 joins a slice of int64 values into a comma-separated string.
|
||||
func (su *SliceUtils) JoinInt64(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
strs := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strs[i] = strconv.FormatInt(v, 10)
|
||||
}
|
||||
return strings.Join(strs, ",")
|
||||
}
|
||||
|
||||
func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct {
|
||||
IsIncluded bool
|
||||
ToggledIDs string
|
||||
},
|
||||
) {
|
||||
ret.IsIncluded = slices.Contains(values, target)
|
||||
if ret.IsIncluded {
|
||||
ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target))
|
||||
} else {
|
||||
ret.ToggledIDs = su.JoinInt64(append(values, target))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -70,6 +70,16 @@ func TestUtils(t *testing.T) {
|
||||
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
|
||||
assert.Equal(t, "false", actual)
|
||||
|
||||
// Test JoinInt64
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}})
|
||||
assert.Equal(t, "1,2,3", actual)
|
||||
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}})
|
||||
assert.Empty(t, actual)
|
||||
|
||||
actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}})
|
||||
assert.Equal(t, "42", actual)
|
||||
|
||||
tmpl := template.New("test")
|
||||
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
|
||||
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
|
||||
|
||||
74
modules/util/diff_slice_test.go
Normal file
74
modules/util/diff_slice_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiffSliceBasic(t *testing.T) {
|
||||
// Typical integer cases
|
||||
t.Run("additions", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2}, []int{1, 2, 3})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("removals", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2, 3}, []int{1, 2})
|
||||
assert.Empty(t, added)
|
||||
assert.Equal(t, []int{3}, removed)
|
||||
})
|
||||
|
||||
t.Run("no changes", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2}, []int{1, 2})
|
||||
assert.Empty(t, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("empty slices", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{}, []int{})
|
||||
assert.Empty(t, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("overlapping elements", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2, 4}, []int{2, 3, 4})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Equal(t, []int{1}, removed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffSliceOrderAndDuplicates(t *testing.T) {
|
||||
oldSlice := []int{1, 2, 2, 3}
|
||||
newSlice := []int{2, 4, 2, 5}
|
||||
|
||||
added, removed := DiffSlice(oldSlice, newSlice)
|
||||
assert.Equal(t, []int{4, 5}, added)
|
||||
assert.Equal(t, []int{1, 3}, removed)
|
||||
}
|
||||
|
||||
func TestDiffSliceDeduplicatesOutput(t *testing.T) {
|
||||
// Test case from issue: newSlice contains [4, 4, 5] and oldSlice is [1]
|
||||
// added should return [4, 5], not [4, 4, 5]
|
||||
t.Run("deduplicates added", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1}, []int{4, 4, 5})
|
||||
assert.Equal(t, []int{4, 5}, added)
|
||||
assert.Equal(t, []int{1}, removed)
|
||||
})
|
||||
|
||||
t.Run("deduplicates removed", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 1, 2}, []int{3})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Equal(t, []int{1, 2}, removed)
|
||||
})
|
||||
|
||||
t.Run("deduplicates both", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 1, 2, 2}, []int{3, 3, 4, 4})
|
||||
assert.Equal(t, []int{3, 4}, added)
|
||||
assert.Equal(t, []int{1, 2}, removed)
|
||||
})
|
||||
}
|
||||
@ -15,6 +15,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
@ -291,3 +293,21 @@ func NormalizeStringEOL(input string) string {
|
||||
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
|
||||
}
|
||||
|
||||
func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
|
||||
oldSet := container.SetOf(oldSlice...)
|
||||
newSet := container.SetOf(newSlice...)
|
||||
|
||||
addedSet, removedSet := container.Set[T]{}, container.Set[T]{}
|
||||
for _, v := range newSlice {
|
||||
if !oldSet.Contains(v) && addedSet.Add(v) {
|
||||
added = append(added, v)
|
||||
}
|
||||
}
|
||||
for _, v := range oldSlice {
|
||||
if !newSet.Contains(v) && removedSet.Add(v) {
|
||||
removed = append(removed, v)
|
||||
}
|
||||
}
|
||||
return added, removed
|
||||
}
|
||||
|
||||
@ -1385,6 +1385,7 @@
|
||||
"repo.projects.column.delete": "Delete Column",
|
||||
"repo.projects.column.deletion_desc": "Deleting a project column moves all related issues to the default column. Continue?",
|
||||
"repo.projects.column.color": "Color",
|
||||
"repo.projects.column": "Column",
|
||||
"repo.projects.open": "Open",
|
||||
"repo.projects.close": "Close",
|
||||
"repo.projects.column.assigned_to": "Assigned to",
|
||||
|
||||
@ -690,11 +690,11 @@ func CreateIssue(ctx *context.APIContext) {
|
||||
form.Labels = make([]int64, 0)
|
||||
}
|
||||
|
||||
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, form.Projects); err != nil {
|
||||
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
ctx.APIError(http.StatusForbidden, err)
|
||||
} else if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@ -913,6 +913,18 @@ func EditIssue(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update projects if provided
|
||||
if canWrite && form.Projects != nil {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil {
|
||||
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusBadRequest, err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch from database to assign some automatic values
|
||||
issue, err = issues_model.GetIssueByID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
|
||||
@ -38,6 +38,14 @@ func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Reposit
|
||||
ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
|
||||
}
|
||||
|
||||
// parseProjectIDsFromQuery parses the comma-separated `project` (preferred) or `projects`
|
||||
// query parameter into a slice of int64 IDs.
|
||||
func parseProjectIDsFromQuery(ctx *context.Context) []int64 {
|
||||
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet
|
||||
// Although here parses the project parameter as a slice, the "search" logic is wrong
|
||||
return ctx.FormStringInt64s("project")
|
||||
}
|
||||
|
||||
// SearchIssues searches for issues across the repositories that the user has access to
|
||||
func SearchIssues(ctx *context.Context) {
|
||||
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||||
@ -156,10 +164,7 @@ func SearchIssues(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
projectID := optional.None[int64]()
|
||||
if v := ctx.FormInt64("project"); v > 0 {
|
||||
projectID = optional.Some(v)
|
||||
}
|
||||
includedProjectIDs := parseProjectIDsFromQuery(ctx)
|
||||
|
||||
// this api is also used in UI,
|
||||
// so the default limit is set to fit UI needs
|
||||
@ -182,7 +187,7 @@ func SearchIssues(ctx *context.Context) {
|
||||
IsClosed: isClosed,
|
||||
IncludedAnyLabelIDs: includedAnyLabels,
|
||||
MilestoneIDs: includedMilestones,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: includedProjectIDs,
|
||||
SortBy: issue_indexer.SortByCreatedDesc,
|
||||
}
|
||||
|
||||
@ -298,11 +303,6 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
projectID := optional.None[int64]()
|
||||
if v := ctx.FormInt64("project"); v > 0 {
|
||||
projectID = optional.Some(v)
|
||||
}
|
||||
|
||||
isPull := optional.None[bool]()
|
||||
switch ctx.FormString("type") {
|
||||
case "pulls":
|
||||
@ -330,13 +330,20 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
|
||||
Page: ctx.FormInt("page"),
|
||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||
},
|
||||
Keyword: keyword,
|
||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||
IsPull: isPull,
|
||||
IsClosed: isClosed,
|
||||
ProjectID: projectID,
|
||||
SortBy: issue_indexer.SortByCreatedDesc,
|
||||
Keyword: keyword,
|
||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||
IsPull: isPull,
|
||||
IsClosed: isClosed,
|
||||
SortBy: issue_indexer.SortByCreatedDesc,
|
||||
}
|
||||
|
||||
projectIDs := parseProjectIDsFromQuery(ctx)
|
||||
if len(projectIDs) == 1 && projectIDs[0] == -1 {
|
||||
searchOpt.NoProjectOnly = true
|
||||
} else if len(projectIDs) > 0 {
|
||||
searchOpt.ProjectIDs = projectIDs
|
||||
}
|
||||
|
||||
if since != 0 {
|
||||
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
||||
}
|
||||
@ -467,7 +474,7 @@ func renderMilestones(ctx *context.Context) {
|
||||
ctx.Data["ClosedMilestones"] = closedMilestones
|
||||
}
|
||||
|
||||
func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
|
||||
func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectIDs []int64, isPullOption optional.Option[bool]) {
|
||||
var err error
|
||||
viewType := ctx.FormString("type")
|
||||
sortType := ctx.FormString("sort")
|
||||
@ -520,7 +527,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
RepoIDs: []int64{repo.ID},
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectIDs,
|
||||
AssigneeID: assigneeID,
|
||||
MentionedID: mentionedID,
|
||||
PosterID: posterUserID,
|
||||
@ -529,6 +536,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
IsPull: isPullOption,
|
||||
IssueIDs: nil,
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
||||
if err != nil {
|
||||
@ -600,7 +608,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
ReviewRequestedID: reviewRequestedID,
|
||||
ReviewedID: reviewedID,
|
||||
MilestoneIDs: mileIDs,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectIDs,
|
||||
IsClosed: isShowClosed,
|
||||
IsPull: isPullOption,
|
||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||
@ -708,7 +716,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
||||
ctx.Data["ViewType"] = viewType
|
||||
ctx.Data["SortType"] = sortType
|
||||
ctx.Data["MilestoneID"] = milestoneID
|
||||
ctx.Data["ProjectID"] = projectID
|
||||
ctx.Data["ProjectIDs"] = projectIDs
|
||||
ctx.Data["AssigneeID"] = assigneeID
|
||||
ctx.Data["PosterUsername"] = posterUsername
|
||||
ctx.Data["Keyword"] = keyword
|
||||
@ -749,7 +757,9 @@ func Issues(ctx *context.Context) {
|
||||
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
}
|
||||
|
||||
prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
|
||||
projectIDs := parseProjectIDsFromQuery(ctx)
|
||||
|
||||
prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), projectIDs, optional.Some(isPullList))
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@ -121,7 +121,8 @@ func NewIssue(ctx *context.Context) {
|
||||
}
|
||||
|
||||
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
||||
pageMetaData.ProjectsData.SelectedProjectIDs, _ = base.StringsToInt64s(strings.Split(ctx.FormString("project"), ","))
|
||||
|
||||
pageMetaData.SetSelectedProjectIDs(parseProjectIDsFromQuery(ctx))
|
||||
if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 {
|
||||
ctx.Data["redirect_after_creation"] = "project"
|
||||
}
|
||||
@ -237,8 +238,9 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item
|
||||
|
||||
// ValidateRepoMetasForNewIssue check and returns repository's meta information
|
||||
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||
LabelIDs, AssigneeIDs []int64
|
||||
MilestoneID, ProjectID int64
|
||||
LabelIDs, AssigneeIDs []int64
|
||||
MilestoneID int64
|
||||
ProjectIDs []int64
|
||||
|
||||
Reviewers []*user_model.User
|
||||
TeamReviewers []*organization.Team
|
||||
@ -249,7 +251,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
||||
return ret
|
||||
}
|
||||
|
||||
inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
|
||||
inputLabelIDs := ctx.FormStringInt64s("label_ids")
|
||||
candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID })
|
||||
if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
|
||||
ctx.NotFound(nil)
|
||||
@ -265,13 +267,8 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
||||
}
|
||||
pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID
|
||||
|
||||
allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...)
|
||||
candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
|
||||
if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) {
|
||||
ctx.NotFound(nil)
|
||||
return ret
|
||||
}
|
||||
pageMetaData.ProjectsData.SelectedProjectIDs = util.Iif(form.ProjectID > 0, []int64{form.ProjectID}, nil)
|
||||
inputProjectIDs := ctx.FormStringInt64s("project_ids")
|
||||
pageMetaData.SetSelectedProjectIDs(inputProjectIDs)
|
||||
|
||||
// prepare assignees
|
||||
candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
|
||||
@ -316,7 +313,8 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
||||
}
|
||||
}
|
||||
|
||||
ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID
|
||||
// Return only the validated IDs.
|
||||
ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectIDs = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, inputProjectIDs
|
||||
ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
|
||||
return ret
|
||||
}
|
||||
@ -324,26 +322,17 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
||||
// NewIssuePost response for creating new issue
|
||||
func NewIssuePost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.CreateIssueForm)
|
||||
ctx.Data["Title"] = ctx.Tr("repo.issues.new")
|
||||
ctx.Data["PageIsIssueList"] = true
|
||||
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
var (
|
||||
repo = ctx.Repo.Repository
|
||||
attachments []string
|
||||
)
|
||||
repo := ctx.Repo.Repository
|
||||
|
||||
validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
|
||||
labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs
|
||||
|
||||
if projectID > 0 {
|
||||
if len(projectIDs) > 0 {
|
||||
if !ctx.Repo.Permission.CanRead(unit.TypeProjects) {
|
||||
// User must also be able to see the project.
|
||||
ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
|
||||
@ -351,6 +340,7 @@ func NewIssuePost(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var attachments []string
|
||||
if setting.Attachment.Enabled {
|
||||
attachments = form.Files
|
||||
}
|
||||
@ -383,7 +373,7 @@ func NewIssuePost(ctx *context.Context) {
|
||||
Ref: form.Ref,
|
||||
}
|
||||
|
||||
if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil {
|
||||
if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectIDs); err != nil {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||
ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
|
||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
@ -395,8 +385,9 @@ func NewIssuePost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
|
||||
project, err := project_model.GetProjectByID(ctx, projectID)
|
||||
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||
// When issue is in multiple projects, redirect to first project from form order.
|
||||
project, err := project_model.GetProjectByID(ctx, projectIDs[0])
|
||||
if err == nil {
|
||||
if project.Type == project_model.TypeOrganization {
|
||||
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
|
||||
|
||||
@ -40,7 +40,7 @@ type issueSidebarProjectCardData struct {
|
||||
}
|
||||
|
||||
type issueSidebarProjectsData struct {
|
||||
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
|
||||
SelectedProjectIDs []int64
|
||||
ProjectCards []*issueSidebarProjectCardData
|
||||
|
||||
OpenProjects []*project_model.Project
|
||||
@ -171,33 +171,49 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
|
||||
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
||||
if d.Issue == nil || d.Issue.Project == nil {
|
||||
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
|
||||
if err := d.Issue.LoadProjects(ctx); err != nil {
|
||||
ctx.ServerError("LoadProjects", err)
|
||||
return
|
||||
}
|
||||
columns, err := d.Issue.Project.GetColumns(ctx)
|
||||
|
||||
// Load column mappings for all projects
|
||||
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetProjectColumns", err)
|
||||
ctx.ServerError("ProjectColumnMap", err)
|
||||
return
|
||||
}
|
||||
columnID, err := d.Issue.ProjectColumnID(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("ProjectColumnID", err)
|
||||
return
|
||||
}
|
||||
var selectedColumn *project_model.Column
|
||||
for _, col := range columns {
|
||||
if col.ID == columnID {
|
||||
selectedColumn = col
|
||||
break
|
||||
|
||||
// Build project cards for each project
|
||||
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
|
||||
for _, project := range d.Issue.Projects {
|
||||
columns, err := project.GetColumns(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetProjectColumns", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{
|
||||
{
|
||||
Project: d.Issue.Project,
|
||||
|
||||
var selectedColumn *project_model.Column
|
||||
columnID := projectColumnMap[project.ID]
|
||||
for _, col := range columns {
|
||||
if col.ID == columnID {
|
||||
selectedColumn = col
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedColumn == nil {
|
||||
selectedColumn, err = project.MustDefaultColumn(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("MustDefaultColumn", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{
|
||||
Project: project,
|
||||
Columns: columns,
|
||||
SelectedColumn: selectedColumn,
|
||||
},
|
||||
})
|
||||
}
|
||||
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
||||
for _, card := range d.ProjectsData.ProjectCards {
|
||||
@ -205,6 +221,29 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
||||
if d.Issue == nil {
|
||||
return
|
||||
}
|
||||
d.retrieveProjectCardsForExistingIssue(ctx)
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) {
|
||||
allProjects := map[int64]*project_model.Project{}
|
||||
for _, p := range d.ProjectsData.OpenProjects {
|
||||
allProjects[p.ID] = p
|
||||
}
|
||||
for _, p := range d.ProjectsData.ClosedProjects {
|
||||
allProjects[p.ID] = p
|
||||
}
|
||||
for _, id := range ids {
|
||||
if project, ok := allProjects[id]; ok {
|
||||
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project})
|
||||
}
|
||||
}
|
||||
d.ProjectsData.SelectedProjectIDs = ids
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
||||
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
||||
}
|
||||
|
||||
@ -238,7 +238,7 @@ func DeleteMilestone(ctx *context.Context) {
|
||||
// MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone
|
||||
func MilestoneIssuesAndPulls(ctx *context.Context) {
|
||||
milestoneID := ctx.PathParamInt64("id")
|
||||
projectID := ctx.FormInt64("project")
|
||||
projectIDs := parseProjectIDsFromQuery(ctx)
|
||||
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
|
||||
if err != nil {
|
||||
if issues_model.IsErrMilestoneNotExist(err) {
|
||||
@ -260,7 +260,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
|
||||
ctx.Data["Title"] = milestone.Name
|
||||
ctx.Data["Milestone"] = milestone
|
||||
|
||||
prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]())
|
||||
prepareIssueFilterAndList(ctx, milestoneID, projectIDs, optional.None[bool]())
|
||||
|
||||
ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
@ -447,13 +448,12 @@ func UpdateIssueProject(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
projectID := ctx.FormInt64("id")
|
||||
projectIDs := ctx.FormStringInt64s("id")
|
||||
var failedIssues []int64
|
||||
for _, issue := range issues {
|
||||
if issue.Project != nil && issue.Project.ID == projectID {
|
||||
continue
|
||||
}
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil {
|
||||
if errors.Is(err, util.ErrPermissionDenied) {
|
||||
failedIssues = append(failedIssues, issue.ID)
|
||||
continue
|
||||
}
|
||||
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
||||
@ -461,6 +461,10 @@ func UpdateIssueProject(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedIssues) > 0 {
|
||||
log.Warn("Failed to assign projects to %d issues due to permission denied: %v", len(failedIssues), failedIssues)
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
@ -477,12 +481,12 @@ func UpdateIssueProjectColumn(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := issue.LoadProject(ctx); err != nil {
|
||||
ctx.ServerError("LoadProject", err)
|
||||
if err := issue.LoadProjects(ctx); err != nil {
|
||||
ctx.ServerError("LoadProjects", err)
|
||||
return
|
||||
}
|
||||
|
||||
issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future
|
||||
issueProjects := issue.Projects
|
||||
|
||||
// it must make sure the requested column is in this issue's projects
|
||||
var columnProject *project_model.Project
|
||||
|
||||
@ -1301,7 +1301,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
|
||||
labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs
|
||||
|
||||
var attachments []string
|
||||
if setting.Attachment.Enabled {
|
||||
@ -1368,7 +1368,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
||||
AssigneeIDs: assigneeIDs,
|
||||
Reviewers: validateRet.Reviewers,
|
||||
TeamReviewers: validateRet.TeamReviewers,
|
||||
ProjectID: projectID,
|
||||
ProjectIDs: projectIDs,
|
||||
}
|
||||
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
||||
switch {
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
@ -35,6 +36,11 @@ func (b *Base) FormStrings(key string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Base) FormStringInt64s(key string) []int64 {
|
||||
vals, _ := base.StringsToInt64s(strings.Split(b.FormString(key), ","))
|
||||
return vals
|
||||
}
|
||||
|
||||
// FormTrim returns the first value for the provided key in the form as a space trimmed string
|
||||
func (b *Base) FormTrim(key string) string {
|
||||
return strings.TrimSpace(b.Req.FormValue(key))
|
||||
|
||||
@ -95,6 +95,13 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss
|
||||
apiIssue.Milestone = ToAPIMilestone(issue.Milestone)
|
||||
}
|
||||
|
||||
if err := issue.LoadProjects(ctx); err != nil {
|
||||
return &api.Issue{}
|
||||
}
|
||||
if len(issue.Projects) > 0 {
|
||||
apiIssue.Projects = ToAPIProjectList(issue.Projects)
|
||||
}
|
||||
|
||||
if err := issue.LoadAssignees(ctx); err != nil {
|
||||
return &api.Issue{}
|
||||
}
|
||||
|
||||
37
services/convert/project.go
Normal file
37
services/convert/project.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convert
|
||||
|
||||
import (
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
// ToAPIProject converts a Project to API format
|
||||
func ToAPIProject(p *project_model.Project) *api.Project {
|
||||
apiProject := &api.Project{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
Description: p.Description,
|
||||
OwnerID: p.OwnerID,
|
||||
RepoID: p.RepoID,
|
||||
CreatorID: p.CreatorID,
|
||||
IsClosed: p.IsClosed,
|
||||
Created: p.CreatedUnix.AsTime(),
|
||||
Updated: p.UpdatedUnix.AsTime(),
|
||||
}
|
||||
if p.IsClosed && p.ClosedDateUnix > 0 {
|
||||
apiProject.Closed = p.ClosedDateUnix.AsTimePtr()
|
||||
}
|
||||
return apiProject
|
||||
}
|
||||
|
||||
// ToAPIProjectList converts a list of Projects to API format
|
||||
func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
|
||||
result := make([]*api.Project, len(projects))
|
||||
for i := range projects {
|
||||
result[i] = ToAPIProject(projects[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -412,12 +412,10 @@ func (f *NewPackagistHookForm) Validate(req *http.Request, errs binding.Errors)
|
||||
// CreateIssueForm form for creating issue
|
||||
type CreateIssueForm struct {
|
||||
Title string `binding:"Required;MaxSize(255)"`
|
||||
LabelIDs string `form:"label_ids"`
|
||||
AssigneeIDs string `form:"assignee_ids"`
|
||||
ReviewerIDs string `form:"reviewer_ids"`
|
||||
Ref string `form:"ref"`
|
||||
MilestoneID int64
|
||||
ProjectID int64
|
||||
Content string
|
||||
Files []string
|
||||
AllowMaintainerEdit bool
|
||||
|
||||
@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
// NewIssue creates new issue with labels for repository.
|
||||
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error {
|
||||
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs, projectIDs []int64) error {
|
||||
if err := issue.LoadPoster(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -41,8 +41,9 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
|
||||
return err
|
||||
}
|
||||
}
|
||||
if projectID > 0 {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
|
||||
if len(projectIDs) > 0 {
|
||||
err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,11 +59,13 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
||||
continue
|
||||
}
|
||||
|
||||
projectColumnID, err := curIssue.ProjectColumnID(ctx)
|
||||
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{
|
||||
@ -80,7 +82,16 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
|
||||
// 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
|
||||
}
|
||||
@ -117,7 +128,7 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu
|
||||
// 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.ProjectID = project.ID
|
||||
o.ProjectIDs = []int64{project.ID}
|
||||
o.SortType = "project-column-sorting"
|
||||
}))
|
||||
if err != nil {
|
||||
@ -211,10 +222,10 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj
|
||||
|
||||
// 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,
|
||||
ProjectIDs: []int64{project.ID},
|
||||
Doer: doer,
|
||||
AllPublic: doer == nil,
|
||||
Owner: project.Owner,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
@ -102,28 +102,18 @@ func Test_Projects(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
column1 := project_model.Column{
|
||||
Title: "column 1",
|
||||
ProjectID: project1.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), &column1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
column2 := project_model.Column{
|
||||
Title: "column 2",
|
||||
ProjectID: project1.ID,
|
||||
}
|
||||
err = project_model.NewColumn(t.Context(), &column2)
|
||||
// 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, project1.ID, column1.ID)
|
||||
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, project1.ID, column1.ID)
|
||||
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{
|
||||
@ -139,8 +129,8 @@ func Test_Projects(t *testing.T) {
|
||||
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
|
||||
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) {
|
||||
@ -149,7 +139,7 @@ func Test_Projects(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[column1.ID], 1) // anonymous user can only visit public repo issues
|
||||
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) {
|
||||
@ -159,7 +149,7 @@ func Test_Projects(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, columnIssues, 1)
|
||||
assert.Len(t, columnIssues[column1.ID], 1) // user4 can only visit public repo issues
|
||||
assert.Len(t, columnIssues[defaultColumn.ID], 1) // user2 can only visit public repo issues
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ type NewPullRequestOptions struct {
|
||||
AssigneeIDs []int64
|
||||
Reviewers []*user_model.User
|
||||
TeamReviewers []*organization.Team
|
||||
ProjectID int64
|
||||
ProjectIDs []int64
|
||||
}
|
||||
|
||||
// NewPullRequest creates new pull request with labels for repository.
|
||||
@ -110,8 +110,8 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
||||
assigneeCommentMap[assigneeID] = comment
|
||||
}
|
||||
|
||||
if opts.ProjectID > 0 && canAssignProject {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectID, 0); err != nil {
|
||||
if len(opts.ProjectIDs) > 0 && canAssignProject {
|
||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{$projectIDs := $.ProjectIDs}}
|
||||
{{$projectIDsQuery := SliceUtils.JoinInt64 $projectIDs}}
|
||||
{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $projectIDsQuery "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{$showAllProjects := not $projectIDs}}
|
||||
{{$showNoProjectSelected := and (eq (len $projectIDs) 1) (eq (index $projectIDs 0) -1)}}
|
||||
|
||||
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
|
||||
|
||||
@ -12,26 +16,28 @@
|
||||
{{end}}
|
||||
|
||||
<!-- Project -->
|
||||
<div class="item ui dropdown jump {{if not (or .OpenProjects .ClosedProjects)}}disabled{{end}}">
|
||||
<div class="item ui dropdown jump project-filter {{if not (or .OpenProjects .ClosedProjects)}}disabled{{end}}">
|
||||
<span class="text">
|
||||
{{ctx.Locale.Tr "repo.issues.filter_project"}}
|
||||
</span>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu">
|
||||
<div class="menu flex-items-menu">
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_project"}}">
|
||||
</div>
|
||||
<a class="{{if not .ProjectID}}active selected {{end}}item" href="{{QueryBuild $queryLink "project" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_project_all"}}</a>
|
||||
<a class="{{if eq .ProjectID -1}}active selected {{end}}item" href="{{QueryBuild $queryLink "project" -1}}">{{ctx.Locale.Tr "repo.issues.filter_project_none"}}</a>
|
||||
<a class="item {{if $showAllProjects}}selected{{end}}" href="{{QueryBuild $queryLink "project" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_project_all"}}</a>
|
||||
<a class="item {{if $showNoProjectSelected}}selected{{end}}" href="{{QueryBuild $queryLink "project" -1}}">{{ctx.Locale.Tr "repo.issues.filter_project_none"}}</a>
|
||||
{{if .OpenProjects}}
|
||||
<div class="divider"></div>
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.issues.new.open_projects"}}
|
||||
</div>
|
||||
{{range .OpenProjects}}
|
||||
<a class="{{if $.ProjectID}}{{if eq $.ProjectID .ID}}active selected{{end}}{{end}} item tw-flex" href="{{QueryBuild $queryLink "project" .ID}}">
|
||||
{{svg .IconName 18 "tw-mr-2 tw-shrink-0"}}<span class="gt-ellipsis">{{.Title}}</span>
|
||||
{{range $project := .OpenProjects}}
|
||||
{{$toggle := SliceUtils.JoinToggleIDs $projectIDs $project.ID}}
|
||||
{{/* FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. If the support comes, here it should use "&project=${toggle.ToggledIDs}" */}}
|
||||
<a class="item {{if $toggle.IsIncluded}}selected{{end}}" href="{{QueryBuild $queryLink "project" $project.ID}}">
|
||||
{{svg $project.IconName}}<span class="gt-ellipsis">{{$project.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -40,9 +46,11 @@
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}
|
||||
</div>
|
||||
{{range .ClosedProjects}}
|
||||
<a class="{{if $.ProjectID}}{{if eq $.ProjectID .ID}}active selected{{end}}{{end}} item" href="{{QueryBuild $queryLink "project" .ID}}">
|
||||
{{svg .IconName 18 "tw-mr-2"}}{{.Title}}
|
||||
{{range $project := .ClosedProjects}}
|
||||
{{$toggle := SliceUtils.JoinToggleIDs $projectIDs $project.ID}}
|
||||
{{/* FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. If the support comes, here it should use "&project=${toggle.ToggledIDs}" */}}
|
||||
<a class="item {{if $toggle.IsIncluded}}selected{{end}}" href="{{QueryBuild $queryLink "project" $project.ID}}">
|
||||
{{svg $project.IconName}}<span class="gt-ellipsis">{{$project.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
{{/* this tmpl is quite dirty, it should not mix unrelated things together .... need to split it in the future*/}}
|
||||
{{$allStatesLink := ""}}{{$openLink := ""}}{{$closedLink := ""}}
|
||||
{{$projectIDsQuery := SliceUtils.JoinInt64 $.ProjectIDs}}
|
||||
{{if .PageIsMilestones}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "sort" $.SortType "state" "all"}}
|
||||
{{else}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" "all" "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" "all" "labels" $.SelectLabels "milestone" $.MilestoneID "project" $projectIDsQuery "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
||||
{{end}}
|
||||
{{$openLink = QueryBuild $allStatesLink "state" "open"}}
|
||||
{{$closedLink = QueryBuild $allStatesLink "state" "closed"}}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<input type="hidden" name="type" value="{{$.ViewType}}">
|
||||
<input type="hidden" name="labels" value="{{$.SelectLabels}}">
|
||||
<input type="hidden" name="milestone" value="{{$.MilestoneID}}">
|
||||
<input type="hidden" name="project" value="{{$.ProjectID}}">
|
||||
<input type="hidden" name="project" value="{{SliceUtils.JoinInt64 $.ProjectIDs}}">
|
||||
<input type="hidden" name="assignee" value="{{$.AssigneeID}}">
|
||||
<input type="hidden" name="poster" value="{{$.PosterUsername}}">
|
||||
<input type="hidden" name="sort" value="{{$.SortType}}">
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
<div class="divider"></div>
|
||||
|
||||
{{/* project selector */}}
|
||||
<div class="issue-sidebar-combo sidebar-project-combo" data-selection-mode="single" data-update-algo="all"
|
||||
<div class="issue-sidebar-combo sidebar-project-combo" data-selection-mode="multiple" data-update-algo="all"
|
||||
{{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
|
||||
>
|
||||
<input class="combo-value" name="project_id" type="hidden" value="{{if and $pageMeta.CanModifyIssueOrPull $data.SelectedProjectIDs}}{{index $data.SelectedProjectIDs 0}}{{end}}">
|
||||
<input class="combo-value" name="project_ids" type="hidden" value="{{SliceUtils.JoinInt64 $data.SelectedProjectIDs}}">
|
||||
<div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
|
||||
<a class="fixed-text muted">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
|
||||
@ -26,7 +26,7 @@
|
||||
{{range $data.OpenProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 gt-ellipsis">{{.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -36,44 +36,37 @@
|
||||
{{range $data.ClosedProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 gt-ellipsis">{{.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* project cards (column selectors) */}}
|
||||
{{if not $data.ProjectCards}}
|
||||
<div class="ui list">
|
||||
<div class="item empty-list">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex-relaxed-list">
|
||||
{{/* project cards (column selectors) */}}
|
||||
<div class="ui list tw-my-2 flex-relaxed-list issue-sidebar-project-cards" data-combo-list-inited="true">
|
||||
<div class="item empty-list {{if $data.ProjectCards}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</div>
|
||||
{{range $card := $data.ProjectCards}}
|
||||
{{$selectedColumn := $card.SelectedColumn}}
|
||||
<div class="item sidebar-project-card">
|
||||
<div class="tw-mb-1">
|
||||
<a class="suppressed flex-text-block" href="{{$card.Project.Link ctx}}">
|
||||
{{svg $card.Project.IconName 16}}
|
||||
<span class="gt-ellipsis">{{$card.Project.Title}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{if $pageMeta.CanModifyIssueOrPull}}
|
||||
{{/* only show a "project column card" if the selected column exists, otherwise only show the project title */}}
|
||||
<div class="item {{if $selectedColumn}}sidebar-project-card{{end}}">
|
||||
<a class="suppressed flex-text-block" href="{{$card.Project.Link ctx}}">
|
||||
{{svg $card.Project.IconName 16}} <span class="gt-ellipsis">{{$card.Project.Title}}</span>
|
||||
</a>
|
||||
{{if and $selectedColumn $pageMeta.CanModifyIssueOrPull}}
|
||||
<div class="issue-sidebar-combo sidebar-project-column-combo" data-selection-mode="single" data-update-algo="all"
|
||||
data-update-url="{{$pageMeta.RepoLink}}/issues/projects/column?issue_id={{$pageMeta.Issue.ID}}"
|
||||
>
|
||||
<input class="combo-value" name="column_id" type="hidden" value="{{if $selectedColumn}}{{$selectedColumn.ID}}{{end}}">
|
||||
<div class="ui dropdown full-width">
|
||||
<div class="flex-text-block tw-ml-[16px]">{{/* align with the "project" icon */}}
|
||||
<div class="interact-bg tw-px-2 tw-py-1 tw-rounded flex-text-block">
|
||||
<div class="interact-bg tw-px-2 tw-py-1 tw-rounded flex-text-block fixed-text">
|
||||
{{if $selectedColumn}}
|
||||
{{if $card.SelectedColumn.Color}}<span class="color-icon icon-size-8" style="background-color: {{$card.SelectedColumn.Color}}"></span>{{end}}
|
||||
<div class="gt-ellipsis" data-testid="sidebar-project-column-text">{{$card.SelectedColumn.Title}}</div>
|
||||
<div class="gt-ellipsis">{{$card.SelectedColumn.Title}}</div>
|
||||
{{else}}
|
||||
<div class="gt-ellipsis" data-testid="sidebar-project-column-text">{{ctx.Locale.Tr "repo.issues.new.no_column"}}</div>
|
||||
<div class="gt-ellipsis">{{ctx.Locale.Tr "repo.issues.new.no_column"}}</div>
|
||||
{{end}}
|
||||
{{svg "octicon-triangle-down" 14}}
|
||||
</div>
|
||||
@ -98,4 +91,4 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -76,10 +76,10 @@
|
||||
<span class="gt-ellipsis">{{.Milestone.Name}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Project}}
|
||||
<a class="project flex-text-inline tw-max-w-[300px]" href="{{.Project.Link ctx}}">
|
||||
{{svg .Project.IconName 14}}
|
||||
<span class="gt-ellipsis">{{.Project.Title}}</span>
|
||||
{{range $project := .Projects}}
|
||||
<a class="project flex-text-inline tw-max-w-[300px]" href="{{$project.Link ctx}}">
|
||||
{{svg $project.IconName 14}}
|
||||
<span class="gt-ellipsis">{{$project.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}}
|
||||
|
||||
86
templates/swagger/v1_json.tmpl
generated
86
templates/swagger/v1_json.tmpl
generated
@ -23841,6 +23841,15 @@
|
||||
"format": "int64",
|
||||
"x-go-name": "Milestone"
|
||||
},
|
||||
"projects": {
|
||||
"description": "list of project ids",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"x-go-name": "Ref"
|
||||
@ -25098,6 +25107,15 @@
|
||||
"format": "int64",
|
||||
"x-go-name": "Milestone"
|
||||
},
|
||||
"projects": {
|
||||
"description": "list of project ids to set (replaces existing projects)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"x-go-name": "Ref"
|
||||
@ -26622,6 +26640,13 @@
|
||||
"format": "int64",
|
||||
"x-go-name": "PinOrder"
|
||||
},
|
||||
"projects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Project"
|
||||
},
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"pull_request": {
|
||||
"$ref": "#/definitions/PullRequestMeta"
|
||||
},
|
||||
@ -27974,6 +27999,67 @@
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"Project": {
|
||||
"description": "Project represents a project",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"closed_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Closed"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Created"
|
||||
},
|
||||
"creator_id": {
|
||||
"description": "CreatorID is the user who created the project",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "CreatorID"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description provides details about the project",
|
||||
"type": "string",
|
||||
"x-go-name": "Description"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the project",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"is_closed": {
|
||||
"description": "IsClosed indicates if the project is closed",
|
||||
"type": "boolean",
|
||||
"x-go-name": "IsClosed"
|
||||
},
|
||||
"owner_id": {
|
||||
"description": "OwnerID is the owner of the project (for org-level projects)",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "OwnerID"
|
||||
},
|
||||
"repo_id": {
|
||||
"description": "RepoID is the repository this project belongs to (for repo-level projects)",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"x-go-name": "RepoID"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title is the title of the project",
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"x-go-name": "Updated"
|
||||
}
|
||||
},
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey publickey is a user key to push code to repository",
|
||||
"type": "object",
|
||||
|
||||
@ -4102,6 +4102,15 @@
|
||||
"type": "integer",
|
||||
"x-go-name": "Milestone"
|
||||
},
|
||||
"projects": {
|
||||
"description": "list of project ids",
|
||||
"items": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"x-go-name": "Ref"
|
||||
@ -5330,6 +5339,15 @@
|
||||
"type": "integer",
|
||||
"x-go-name": "Milestone"
|
||||
},
|
||||
"projects": {
|
||||
"description": "list of project ids to set (replaces existing projects)",
|
||||
"items": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"x-go-name": "Ref"
|
||||
@ -6849,6 +6867,13 @@
|
||||
"type": "integer",
|
||||
"x-go-name": "PinOrder"
|
||||
},
|
||||
"projects": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Project"
|
||||
},
|
||||
"type": "array",
|
||||
"x-go-name": "Projects"
|
||||
},
|
||||
"pull_request": {
|
||||
"$ref": "#/components/schemas/PullRequestMeta"
|
||||
},
|
||||
@ -8215,6 +8240,67 @@
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"Project": {
|
||||
"description": "Project represents a project",
|
||||
"properties": {
|
||||
"closed_at": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Closed"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Created"
|
||||
},
|
||||
"creator_id": {
|
||||
"description": "CreatorID is the user who created the project",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "CreatorID"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description provides details about the project",
|
||||
"type": "string",
|
||||
"x-go-name": "Description"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the unique identifier for the project",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "ID"
|
||||
},
|
||||
"is_closed": {
|
||||
"description": "IsClosed indicates if the project is closed",
|
||||
"type": "boolean",
|
||||
"x-go-name": "IsClosed"
|
||||
},
|
||||
"owner_id": {
|
||||
"description": "OwnerID is the owner of the project (for org-level projects)",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "OwnerID"
|
||||
},
|
||||
"repo_id": {
|
||||
"description": "RepoID is the repository this project belongs to (for repo-level projects)",
|
||||
"format": "int64",
|
||||
"type": "integer",
|
||||
"x-go-name": "RepoID"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title is the title of the project",
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
},
|
||||
"updated_at": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"x-go-name": "Updated"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey publickey is a user key to push code to repository",
|
||||
"properties": {
|
||||
|
||||
@ -20,7 +20,7 @@ test.describe('events', () => {
|
||||
await expect(badge).toBeHidden();
|
||||
|
||||
// Create issue as another user — this generates a notification delivered via server push
|
||||
await apiCreateIssue(request, owner, repoName, {title: 'events notification test', headers: apiUserHeaders(commenter)});
|
||||
await apiCreateIssue(request, {owner, repo: repoName, title: 'events notification test', headers: apiUserHeaders(commenter)});
|
||||
|
||||
// Wait for the notification badge to appear via server event
|
||||
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
|
||||
@ -37,7 +37,7 @@ test.describe('events', () => {
|
||||
loginUser(page, name),
|
||||
(async () => {
|
||||
await apiCreateRepo(request, {name, headers});
|
||||
await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers});
|
||||
await apiCreateIssue(request, {owner: name, repo: name, title: 'events stopwatch test', headers});
|
||||
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||
})(),
|
||||
]);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, createProjectColumn, randomString} from './utils.ts';
|
||||
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProject, createProjectColumn, randomString} from './utils.ts';
|
||||
|
||||
test('assign issue to project and change column', async ({page}) => {
|
||||
const repoName = `e2e-issue-project-${randomString(8)}`;
|
||||
@ -16,14 +16,428 @@ test('assign issue to project and change column', async ({page}) => {
|
||||
// columns created via POST because the web UI uses modals that are hard to drive
|
||||
await Promise.all([
|
||||
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
|
||||
apiCreateIssue(page.request, user, repoName, {title: 'Column picker test'}),
|
||||
apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Column picker test'}),
|
||||
]);
|
||||
await page.goto(`/${user}/${repoName}/issues/1`);
|
||||
await page.locator('.sidebar-project-combo .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-combo .menu a.item', {hasText: 'Kanban Board'}).click();
|
||||
const columnCombo = page.locator('.sidebar-project-column-combo');
|
||||
await expect(columnCombo).toBeVisible();
|
||||
await columnCombo.locator('.ui.dropdown').click();
|
||||
await columnCombo.locator('.menu a.item', {hasText: 'In Progress'}).click();
|
||||
await expect(columnCombo.getByTestId('sidebar-project-column-text')).toHaveText('In Progress');
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
|
||||
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
|
||||
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
|
||||
await apiDeleteRepo(page.request, user, repoName);
|
||||
});
|
||||
|
||||
test('create a project', async ({page}) => {
|
||||
const repoName = `e2e-project-repo-${Date.now()}`;
|
||||
const projectTitle = 'Test Project';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Navigate to new project page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/new`);
|
||||
|
||||
// Fill in project details
|
||||
await page.getByLabel('Title').fill(projectTitle);
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
// Verify project was created by checking we're redirected to the projects list
|
||||
await expect(page).toHaveURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects$`));
|
||||
|
||||
// Verify the project appears in the list
|
||||
await expect(page.locator('.milestone-list')).toContainText(projectTitle);
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('assign issue to multiple projects via sidebar', async ({page}) => {
|
||||
const repoName = `e2e-multi-project-${Date.now()}`;
|
||||
const project1Title = 'Project Alpha';
|
||||
const project2Title = 'Project Beta';
|
||||
const issueTitle = 'Test issue for multiple projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create an issue without any project
|
||||
const issue = await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: issueTitle,
|
||||
});
|
||||
|
||||
// Navigate to the issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
||||
|
||||
// Open the projects dropdown in the sidebar
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown and trigger the update
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify both projects are shown in the sidebar
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('create issue with multiple projects pre-selected', async ({page}) => {
|
||||
const repoName = `e2e-issue-multi-proj-${Date.now()}`;
|
||||
const project1Title = 'Project One';
|
||||
const project2Title = 'Project Two';
|
||||
const issueTitle = 'Issue with multiple projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Navigate to new issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
||||
|
||||
// Fill in the issue title
|
||||
await page.locator('input[name="title"]').fill(issueTitle);
|
||||
|
||||
// Open the projects dropdown
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Issue'}).click();
|
||||
|
||||
// Wait for issue to be created and page to redirect
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/\\d+`));
|
||||
|
||||
// Verify both projects are shown in the sidebar
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('filter issues by multiple projects in issue list', async ({page}) => {
|
||||
const repoName = `e2e-filter-projects-${Date.now()}`;
|
||||
const project1Title = 'Filter Project A';
|
||||
const project2Title = 'Filter Project B';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create issues: one in project1, one in project2, one in both
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in Project A only',
|
||||
projects: [project1.id],
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in Project B only',
|
||||
projects: [project2.id],
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue in both projects',
|
||||
projects: [project1.id, project2.id],
|
||||
});
|
||||
// Create an issue with no project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue with no project',
|
||||
});
|
||||
|
||||
// Verify only project1 issues are visible
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project1.id}`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in Project A only');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project B only');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
||||
|
||||
// Verify only project2 issues are visible
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project2.id}`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in Project B only');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project A only');
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('remove issue from one project keeping others', async ({page}) => {
|
||||
const repoName = `e2e-remove-project-${Date.now()}`;
|
||||
const project1Title = 'Keep This Project';
|
||||
const project2Title = 'Remove This Project';
|
||||
const issueTitle = 'Issue to modify projects';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Create an issue in both projects
|
||||
const issue = await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: issueTitle,
|
||||
projects: [project1.id, project2.id],
|
||||
});
|
||||
|
||||
// Navigate to the issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
||||
|
||||
// Verify both projects are initially shown
|
||||
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeVisible();
|
||||
|
||||
// Open the projects dropdown
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Deselect project2 (click on the already selected item to deselect)
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close the dropdown and trigger the update
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify project1 is still shown but project2 is removed
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project1Title}")`)).toBeVisible();
|
||||
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeHidden();
|
||||
|
||||
// Reload the page to see the timeline comment
|
||||
await page.reload();
|
||||
|
||||
// Verify the timeline shows "removed this from the project" comment
|
||||
const timelineComments = page.locator('.timeline-item.event');
|
||||
await expect(timelineComments.filter({hasText: 'removed this from the'})).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('filter issues with no project using project=-1', async ({page}) => {
|
||||
const repoName = `e2e-no-project-filter-${Date.now()}`;
|
||||
const projectTitle = 'Some Project';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create a project via UI
|
||||
const project = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: projectTitle,
|
||||
});
|
||||
|
||||
// Create an issue with a project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue with project assigned',
|
||||
projects: [project.id],
|
||||
});
|
||||
|
||||
// Create issues with no project
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Issue without any project',
|
||||
});
|
||||
await apiCreateIssue(page.request, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: 'Another unassigned issue',
|
||||
});
|
||||
|
||||
// First verify we can see all issues without the filter
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open`);
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue with project assigned');
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
||||
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
||||
|
||||
// Navigate to issue list filtering for issues with no project (project=-1)
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open&project=-1`);
|
||||
|
||||
// Verify only issues with no project are visible
|
||||
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
||||
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
||||
|
||||
// Verify the issue with a project is NOT visible
|
||||
await expect(page.locator('#issue-list')).not.toContainText('Issue with project assigned');
|
||||
|
||||
// Verify the last item in the list is NOT the issue with a project
|
||||
const issueItems = page.locator('#issue-list .flex-item');
|
||||
const lastIssueItem = issueItems.last();
|
||||
await expect(lastIssueItem).not.toContainText('Issue with project assigned');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('close project and view in closed projects list', async ({page}) => {
|
||||
const repoName = `e2e-close-project-${Date.now()}`;
|
||||
const openProjectTitle = 'Open Project';
|
||||
const closedProjectTitle = 'Project To Close';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects via UI
|
||||
await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: openProjectTitle,
|
||||
});
|
||||
const projectToClose = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: closedProjectTitle,
|
||||
});
|
||||
|
||||
// Navigate to projects list
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
||||
|
||||
// Verify both projects are visible in open state
|
||||
await expect(page.locator('.milestone-list')).toContainText(openProjectTitle);
|
||||
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
||||
|
||||
// Close the second project by clicking the close link
|
||||
const projectCard = page.locator('.milestone-card').filter({hasText: closedProjectTitle});
|
||||
await projectCard.locator('a.link-action[data-url$="/close"]').click();
|
||||
|
||||
// Wait for redirect back to project view page
|
||||
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/${projectToClose.id}`));
|
||||
|
||||
// Navigate to projects list
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
||||
|
||||
// Click on "Closed" tab to view closed projects
|
||||
await page.locator('.list-header-toggle a.item').filter({hasText: 'Closed'}).click();
|
||||
|
||||
// Wait for the page to load with closed projects
|
||||
await page.waitForURL(/state=closed/);
|
||||
|
||||
// Verify only the closed project is visible
|
||||
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
||||
await expect(page.locator('.milestone-list')).not.toContainText(openProjectTitle);
|
||||
|
||||
// Verify the "Closed" tab is active
|
||||
await expect(page.locator('.list-header-toggle a.item.active')).toContainText('Closed');
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
test('select projects on new issue page shows in sidebar', async ({page}) => {
|
||||
const repoName = `e2e-new-issue-project-${Date.now()}`;
|
||||
const project1Title = 'Project One';
|
||||
const project2Title = 'Project Two';
|
||||
|
||||
await login(page);
|
||||
await apiCreateRepo(page.request, {name: repoName});
|
||||
|
||||
try {
|
||||
// Create two projects
|
||||
const project1 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project1Title,
|
||||
});
|
||||
const project2 = await createProject(page, {
|
||||
owner: env.GITEA_TEST_E2E_USER,
|
||||
repo: repoName,
|
||||
title: project2Title,
|
||||
});
|
||||
|
||||
// Navigate to new issue page
|
||||
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
||||
|
||||
// Open the projects dropdown in the sidebar
|
||||
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||
|
||||
// Select both projects
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
||||
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
||||
|
||||
// Click outside to close dropdown
|
||||
await page.locator('.issue-content-left').click();
|
||||
|
||||
// Verify both projects appear in the sidebar list below the dropdown
|
||||
// On new issue page, these are simple cloned items rendered in the list container
|
||||
const projectList = page.locator('.sidebar-project-combo > .ui.list');
|
||||
await expect(projectList.locator(`.item:has-text("${project1Title}")`).first()).toBeVisible();
|
||||
await expect(projectList.locator(`.item:has-text("${project2Title}")`).first()).toBeVisible();
|
||||
} finally {
|
||||
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
||||
}
|
||||
});
|
||||
|
||||
@ -7,7 +7,7 @@ test('toggle issue reactions', async ({page, request}) => {
|
||||
const owner = env.GITEA_TEST_E2E_USER;
|
||||
await apiCreateRepo(request, {name: repoName});
|
||||
await Promise.all([
|
||||
apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}),
|
||||
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
|
||||
login(page),
|
||||
]);
|
||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||
|
||||
@ -47,13 +47,6 @@ export async function apiCreateRepo(requestContext: APIRequestContext, {name, au
|
||||
}), 'apiCreateRepo');
|
||||
}
|
||||
|
||||
export async function apiCreateIssue(requestContext: APIRequestContext, owner: string, repo: string, {title, headers}: {title: string; headers?: Record<string, string>}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title},
|
||||
}), 'apiCreateIssue');
|
||||
}
|
||||
|
||||
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/start`, {
|
||||
headers: headers || apiHeaders(),
|
||||
@ -135,6 +128,63 @@ export async function apiDeleteUser(requestContext: APIRequestContext, username:
|
||||
}), 'apiDeleteUser');
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
page: Page,
|
||||
{owner, repo, title}: {owner: string; repo: string; title: string},
|
||||
): Promise<{id: number}> {
|
||||
// Navigate to new project page
|
||||
await page.goto(`/${owner}/${repo}/projects/new`);
|
||||
|
||||
// Fill in project details
|
||||
await page.getByLabel('Title').fill(title);
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
// Wait for redirect to projects list
|
||||
await page.waitForURL(new RegExp(`/${owner}/${repo}/projects$`));
|
||||
|
||||
// Extract the project ID from the project link in the list
|
||||
const projectLink = page.locator('.milestone-list .milestone-card').filter({hasText: title}).locator('a').first();
|
||||
const href = await projectLink.getAttribute('href');
|
||||
const match = /\/projects\/(\d+)/.exec(href || '');
|
||||
const id = match ? parseInt(match[1]) : 0;
|
||||
|
||||
return {id};
|
||||
}
|
||||
|
||||
export async function apiCreateIssue(
|
||||
requestContext: APIRequestContext,
|
||||
{owner, repo, title, body, projects, headers}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
projects?: number[];
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Promise<{index: number}> {
|
||||
let result: {index: number} = {index: 0};
|
||||
await apiRetry(async () => {
|
||||
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {title, body: body || '', projects: projects || []},
|
||||
});
|
||||
if (response.ok()) {
|
||||
const json = await response.json();
|
||||
// API returns "number" field for the issue index
|
||||
result = {index: json.number};
|
||||
}
|
||||
return response;
|
||||
}, 'apiCreateIssue');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
|
||||
await trigger.click();
|
||||
await page.getByText(itemText).click();
|
||||
}
|
||||
|
||||
export async function loginUser(page: Page, username: string) {
|
||||
return login(page, username, testUserPassword);
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ func TestAPIIssue(t *testing.T) {
|
||||
t.Run("IssueContentVersion", testAPIIssueContentVersion)
|
||||
t.Run("CreateIssue", testAPICreateIssue)
|
||||
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
|
||||
t.Run("IssueProjects", testAPIIssueProjects)
|
||||
}
|
||||
|
||||
func testAPIListIssues(t *testing.T) {
|
||||
@ -496,3 +497,66 @@ func testAPIIssueContentVersion(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
})
|
||||
}
|
||||
|
||||
func testAPIIssueProjects(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
|
||||
session := loginUser(t, owner.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
||||
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name)
|
||||
|
||||
// Create issue with a project
|
||||
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with project",
|
||||
Body: "test body",
|
||||
Projects: []int64{1},
|
||||
}).AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusCreated)
|
||||
var apiIssue api.Issue
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Get issue should include projects
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index)).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Edit issue to remove projects
|
||||
emptyProjects := []int64{}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &emptyProjects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Empty(t, apiIssue.Projects)
|
||||
|
||||
// Edit issue to add project back
|
||||
projects := []int64{1}
|
||||
req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("%s/%d", urlStr, apiIssue.Index), &api.EditIssueOption{
|
||||
Projects: &projects,
|
||||
}).AddTokenAuth(token)
|
||||
resp = MakeRequest(t, req, http.StatusCreated)
|
||||
DecodeJSON(t, resp, &apiIssue)
|
||||
assert.Len(t, apiIssue.Projects, 1)
|
||||
assert.EqualValues(t, 1, apiIssue.Projects[0].ID)
|
||||
|
||||
// Test invalid project ID
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with invalid project",
|
||||
Body: "test body",
|
||||
Projects: []int64{99999},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
|
||||
// Test project from different repo (project 2 is for repo 3)
|
||||
req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
|
||||
Title: "issue with inaccessible project",
|
||||
Body: "test body",
|
||||
Projects: []int64{2},
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@ -90,6 +90,26 @@ func TestMoveRepoProjectColumns(t *testing.T) {
|
||||
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
|
||||
}
|
||||
|
||||
func TestUpdateIssueProject(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
t.Run("AssignAndRemove", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=2", map[string]string{
|
||||
"id": "",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{IssueID: 2, ProjectID: 1})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
@ -160,13 +180,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards := htmlDoc.Find(".sidebar-project-card")
|
||||
cards := htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 1, cards.Length())
|
||||
|
||||
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
|
||||
title := cards.Find("a span.gt-ellipsis")
|
||||
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
|
||||
|
||||
columnCombo := cards.Find(".sidebar-project-column-combo")
|
||||
columnCombo := cards.Find(".issue-sidebar-combo.sidebar-project-column-combo")
|
||||
assert.Equal(t, 1, columnCombo.Length())
|
||||
|
||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||
@ -181,16 +201,14 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "3", comboVal)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
||||
"id": "0",
|
||||
})
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards = htmlDoc.Find(".sidebar-project-card")
|
||||
cards = htmlDoc.Find(".flex-relaxed-list > .item.sidebar-project-card")
|
||||
assert.Equal(t, 0, cards.Length())
|
||||
}
|
||||
|
||||
@ -293,15 +311,9 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), &project))
|
||||
|
||||
// Get the default column
|
||||
columns, err := project.GetColumns(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, columns)
|
||||
defaultColumnID := columns[0].ID
|
||||
|
||||
// Add issues to the project
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user1, []int64{project.ID}))
|
||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
|
||||
|
||||
sess := loginUser(t, "user1")
|
||||
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)
|
||||
|
||||
@ -897,7 +897,7 @@ table th[data-sortt-desc] .svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-block);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-left-right > .ui.button,
|
||||
|
||||
@ -62,10 +62,17 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar-project-card {
|
||||
.ui.list.issue-sidebar-project-cards > .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-block);
|
||||
}
|
||||
|
||||
.ui.list.issue-sidebar-project-cards > .item.sidebar-project-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: var(--gap-block);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ export class IssueSidebarComboList {
|
||||
|
||||
updateUiList(changedValues: Array<string>) {
|
||||
if (!this.elList) return;
|
||||
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
|
||||
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
|
||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
@ -139,7 +139,7 @@ export class IssueSidebarComboList {
|
||||
async doUpdate() {
|
||||
const changedValues = this.collectCheckedValues();
|
||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||
this.updateUiList(changedValues);
|
||||
if (!this.updateUrl) this.updateUiList(changedValues);
|
||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||
this.initialValues = changedValues;
|
||||
}
|
||||
@ -196,7 +196,9 @@ export class IssueSidebarComboList {
|
||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
elItem?.classList.add('checked');
|
||||
}
|
||||
this.updateUiList(values);
|
||||
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
|
||||
this.updateUiList(values);
|
||||
}
|
||||
}
|
||||
this.initialValues = this.collectCheckedValues();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user