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:
co-authored by
GitHub
Tyrone Yeh
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Copilot
OpenCode
wxiaoguang
silverwind
Claude
parent
52d6baf5a8
commit
81692ceafa
@@ -65,6 +65,7 @@ cpu.out
|
|||||||
/indexers
|
/indexers
|
||||||
/log
|
/log
|
||||||
/public/assets/img/avatar
|
/public/assets/img/avatar
|
||||||
|
/tests/e2e-output
|
||||||
/tests/integration/gitea-integration-*
|
/tests/integration/gitea-integration-*
|
||||||
/tests/integration/indexers-*
|
/tests/integration/indexers-*
|
||||||
/tests/*.ini
|
/tests/*.ini
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ playwright: deps-frontend
|
|||||||
@pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium firefox $(PLAYWRIGHT_FLAGS)
|
@pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium firefox $(PLAYWRIGHT_FLAGS)
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
test-e2e: playwright backend
|
test-e2e: playwright frontend backend
|
||||||
@EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS)
|
@EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS)
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|||||||
+14
-12
@@ -59,17 +59,18 @@ type Issue struct {
|
|||||||
PosterID int64 `xorm:"INDEX"`
|
PosterID int64 `xorm:"INDEX"`
|
||||||
Poster *user_model.User `xorm:"-"`
|
Poster *user_model.User `xorm:"-"`
|
||||||
OriginalAuthor string
|
OriginalAuthor string
|
||||||
OriginalAuthorID int64 `xorm:"index"`
|
OriginalAuthorID int64 `xorm:"index"`
|
||||||
Title string `xorm:"name"`
|
Title string `xorm:"name"`
|
||||||
Content string `xorm:"LONGTEXT"`
|
Content string `xorm:"LONGTEXT"`
|
||||||
RenderedContent template.HTML `xorm:"-"`
|
RenderedContent template.HTML `xorm:"-"`
|
||||||
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
|
||||||
Labels []*Label `xorm:"-"`
|
Labels []*Label `xorm:"-"`
|
||||||
isLabelsLoaded bool `xorm:"-"`
|
isLabelsLoaded bool `xorm:"-"`
|
||||||
MilestoneID int64 `xorm:"INDEX"`
|
MilestoneID int64 `xorm:"INDEX"`
|
||||||
Milestone *Milestone `xorm:"-"`
|
Milestone *Milestone `xorm:"-"`
|
||||||
isMilestoneLoaded bool `xorm:"-"`
|
isMilestoneLoaded bool `xorm:"-"`
|
||||||
Project *project_model.Project `xorm:"-"`
|
Projects []*project_model.Project `xorm:"-"`
|
||||||
|
isProjectsLoaded bool `xorm:"-"`
|
||||||
Priority int
|
Priority int
|
||||||
AssigneeID int64 `xorm:"-"`
|
AssigneeID int64 `xorm:"-"`
|
||||||
Assignee *user_model.User `xorm:"-"`
|
Assignee *user_model.User `xorm:"-"`
|
||||||
@@ -305,7 +306,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = issue.LoadProject(ctx); err != nil {
|
if err = issue.LoadProjects(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +356,7 @@ func (issue *Issue) ResetAttributesLoaded() {
|
|||||||
issue.isMilestoneLoaded = false
|
issue.isMilestoneLoaded = false
|
||||||
issue.isAttachmentsLoaded = false
|
issue.isAttachmentsLoaded = false
|
||||||
issue.isAssigneeLoaded = false
|
issue.isAssigneeLoaded = false
|
||||||
|
issue.isProjectsLoaded = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIsRead load the `IsRead` field of the issue
|
// 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 {
|
func (issues IssueList) LoadProjects(ctx context.Context) error {
|
||||||
issueIDs := issues.getIssueIDs()
|
issueIDs := issues.getIssueIDs()
|
||||||
projectMaps := make(map[int64]*project_model.Project, len(issues))
|
issueProjectMaps := make(map[int64][]*project_model.Project, len(issues))
|
||||||
left := len(issueIDs)
|
left := len(issueIDs)
|
||||||
|
|
||||||
type projectWithIssueID struct {
|
type projectWithIssueID struct {
|
||||||
@@ -202,19 +202,21 @@ func (issues IssueList) LoadProjects(ctx context.Context) error {
|
|||||||
Select("project.*, project_issue.issue_id").
|
Select("project.*, project_issue.issue_id").
|
||||||
Join("INNER", "project_issue", "project.id = project_issue.project_id").
|
Join("INNER", "project_issue", "project.id = project_issue.project_id").
|
||||||
In("project_issue.issue_id", issueIDs[:limit]).
|
In("project_issue.issue_id", issueIDs[:limit]).
|
||||||
|
OrderBy("project_issue.issue_id ASC, project.id ASC").
|
||||||
Find(&projects)
|
Find(&projects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, project := range projects {
|
for _, project := range projects {
|
||||||
projectMaps[project.IssueID] = project.Project
|
issueProjectMaps[project.IssueID] = append(issueProjectMaps[project.IssueID], project.Project)
|
||||||
}
|
}
|
||||||
left -= limit
|
left -= limit
|
||||||
issueIDs = issueIDs[limit:]
|
issueIDs = issueIDs[limit:]
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
issue.Project = projectMaps[issue.ID]
|
issue.Projects = issueProjectMaps[issue.ID]
|
||||||
|
issue.isProjectsLoaded = true
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ func TestIssueList_LoadAttributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if issue.ID == int64(1) {
|
if issue.ID == int64(1) {
|
||||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||||
assert.NotNil(t, issue.Project)
|
assert.NotEmpty(t, issue.Projects)
|
||||||
assert.Equal(t, int64(1), issue.Project.ID)
|
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||||
} else {
|
} else {
|
||||||
assert.Nil(t, issue.Project)
|
assert.Empty(t, issue.Projects)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-79
@@ -12,41 +12,38 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadProject load the project the issue was assigned to
|
// LoadProjects loads all projects the issue is assigned to
|
||||||
func (issue *Issue) LoadProject(ctx context.Context) (err error) {
|
func (issue *Issue) LoadProjects(ctx context.Context) (err error) {
|
||||||
if issue.Project == nil {
|
if !issue.isProjectsLoaded {
|
||||||
var p project_model.Project
|
err = db.GetEngine(ctx).Table("project").
|
||||||
has, err := db.GetEngine(ctx).Table("project").
|
|
||||||
Join("INNER", "project_issue", "project.id=project_issue.project_id").
|
Join("INNER", "project_issue", "project.id=project_issue.project_id").
|
||||||
Where("project_issue.issue_id = ?", issue.ID).Get(&p)
|
Where("project_issue.issue_id = ?", issue.ID).
|
||||||
if err != nil {
|
OrderBy("project.id ASC").
|
||||||
return err
|
Find(&issue.Projects)
|
||||||
} else if has {
|
if err == nil {
|
||||||
issue.Project = &p
|
issue.isProjectsLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (issue *Issue) projectID(ctx context.Context) int64 {
|
func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) {
|
||||||
var ip project_model.ProjectIssue
|
err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs)
|
||||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
return projectIDs, err
|
||||||
if err != nil || !has {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return ip.ProjectID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectColumnID return project column id if issue was assigned to one
|
// ProjectColumnMap returns a map of project ID to column ID for this issue.
|
||||||
func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
|
func (issue *Issue) ProjectColumnMap(ctx context.Context) (map[int64]int64, error) {
|
||||||
var ip project_model.ProjectIssue
|
var projIssues []project_model.ProjectIssue
|
||||||
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
|
if err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Find(&projIssues); err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return 0, err
|
|
||||||
} else if !has {
|
|
||||||
return 0, nil
|
|
||||||
}
|
}
|
||||||
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) {
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueAssignOrRemoveProject changes the project associated with an issue
|
// IssueAssignOrRemoveProject updates the projects associated with an issue.
|
||||||
// If newProjectID is 0, the issue is removed from the project
|
// It adds projects that are in newProjectIDs but not currently assigned,
|
||||||
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
|
// 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 {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
oldProjectID := issue.projectID(ctx)
|
|
||||||
|
|
||||||
if err := issue.LoadRepo(ctx); err != nil {
|
if err := issue.LoadRepo(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check if we add a new project and not remove it.
|
oldProjectIDs, err := issue.projectIDs(ctx)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
|
||||||
IssueID: issue.ID,
|
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)
|
||||||
ProjectID: newProjectID,
|
issue.isProjectsLoaded = false
|
||||||
ProjectColumnID: newColumnID,
|
issue.Projects = nil
|
||||||
Sorting: newSorting,
|
|
||||||
})
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
@@ -36,8 +37,7 @@ type IssuesOptions struct { //nolint:revive // export stutter
|
|||||||
ReviewedID int64
|
ReviewedID int64
|
||||||
SubscriberID int64
|
SubscriberID int64
|
||||||
MilestoneIDs []int64
|
MilestoneIDs []int64
|
||||||
ProjectID int64
|
ProjectIDs []int64
|
||||||
ProjectColumnID int64
|
|
||||||
IsClosed optional.Option[bool]
|
IsClosed optional.Option[bool]
|
||||||
IsPull optional.Option[bool]
|
IsPull optional.Option[bool]
|
||||||
LabelIDs []int64
|
LabelIDs []int64
|
||||||
@@ -198,26 +198,19 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
|
func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) {
|
||||||
if opts.ProjectID > 0 { // specific project
|
projectIDs := util.SliceRemoveAll(opts.ProjectIDs, 0)
|
||||||
sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
|
if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project
|
||||||
And("project_issue.project_id=?", opts.ProjectID)
|
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue")))
|
||||||
} else if opts.ProjectID == db.NoConditionID { // show those that are in no project
|
} else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project
|
||||||
sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0})))
|
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
|
// 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) {
|
func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) {
|
||||||
if len(opts.RepoIDs) == 1 {
|
if len(opts.RepoIDs) == 1 {
|
||||||
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
|
opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
|
||||||
@@ -276,8 +269,6 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
|
|||||||
|
|
||||||
applyProjectCondition(sess, opts)
|
applyProjectCondition(sess, opts)
|
||||||
|
|
||||||
applyProjectColumnCondition(sess, opts)
|
|
||||||
|
|
||||||
if opts.IsPull.Has() {
|
if opts.IsPull.Has() {
|
||||||
sess.And("issue.is_pull=?", opts.IsPull.Value())
|
sess.And("issue.is_pull=?", opts.IsPull.Value())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,10 +424,10 @@ func TestIssueLoadAttributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if issue.ID == int64(1) {
|
if issue.ID == int64(1) {
|
||||||
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
assert.Equal(t, int64(400), issue.TotalTrackedTime)
|
||||||
assert.NotNil(t, issue.Project)
|
assert.NotEmpty(t, issue.Projects)
|
||||||
assert.Equal(t, int64(1), issue.Project.ID)
|
assert.Equal(t, int64(1), issue.Projects[0].ID)
|
||||||
} else {
|
} 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
|
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) {
|
func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) {
|
||||||
p := new(Project)
|
p := new(Project)
|
||||||
has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p)
|
has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
issueIndexerAnalyzer = "issueIndexer"
|
issueIndexerAnalyzer = "issueIndexer"
|
||||||
issueIndexerDocType = "issueIndexerDocType"
|
issueIndexerDocType = "issueIndexerDocType"
|
||||||
issueIndexerLatestVersion = 5
|
issueIndexerLatestVersion = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
const unicodeNormalizeName = "unicodeNormalize"
|
const unicodeNormalizeName = "unicodeNormalize"
|
||||||
@@ -83,8 +83,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
|
|||||||
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
|
docMapping.AddFieldMappingsAt("no_label", boolFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("project_id", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("no_project", boolFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
|
docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping)
|
||||||
docMapping.AddFieldMappingsAt("mention_ids", 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...))
|
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.ProjectID.Has() {
|
if options.NoProjectOnly {
|
||||||
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
|
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project"))
|
||||||
}
|
} else if len(options.ProjectIDs) > 0 {
|
||||||
if options.ProjectColumnID.Has() {
|
var projectQueries []query.Query
|
||||||
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
|
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 != "" {
|
if options.PosterID != "" {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/indexer/issues/internal"
|
"code.gitea.io/gitea/modules/indexer/issues/internal"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) {
|
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),
|
ReviewRequestedID: convertID(options.ReviewRequestedID),
|
||||||
ReviewedID: convertID(options.ReviewedID),
|
ReviewedID: convertID(options.ReviewedID),
|
||||||
SubscriberID: convertID(options.SubscriberID),
|
SubscriberID: convertID(options.SubscriberID),
|
||||||
ProjectID: convertID(options.ProjectID),
|
ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs),
|
||||||
ProjectColumnID: convertID(options.ProjectColumnID),
|
|
||||||
IsClosed: options.IsClosed,
|
IsClosed: options.IsClosed,
|
||||||
IsPull: options.IsPull,
|
IsPull: options.IsPull,
|
||||||
IncludedLabelNames: nil,
|
IncludedLabelNames: nil,
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
|||||||
searchOpt.MilestoneIDs = opts.MilestoneIDs
|
searchOpt.MilestoneIDs = opts.MilestoneIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.ProjectID > 0 {
|
if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID {
|
||||||
searchOpt.ProjectID = optional.Some(opts.ProjectID)
|
searchOpt.NoProjectOnly = true
|
||||||
} else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places
|
} else {
|
||||||
searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0)
|
searchOpt.ProjectIDs = opts.ProjectIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
searchOpt.AssigneeID = opts.AssigneeID
|
searchOpt.AssigneeID = opts.AssigneeID
|
||||||
@@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID)
|
|
||||||
searchOpt.PosterID = opts.PosterID
|
searchOpt.PosterID = opts.PosterID
|
||||||
searchOpt.MentionID = convertID(opts.MentionedID)
|
searchOpt.MentionID = convertID(opts.MentionedID)
|
||||||
searchOpt.ReviewedID = convertID(opts.ReviewedID)
|
searchOpt.ReviewedID = convertID(opts.ReviewedID)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
issueIndexerLatestVersion = 2
|
issueIndexerLatestVersion = 3
|
||||||
// multi-match-types, currently only 2 types are used
|
// 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
|
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
|
||||||
esMultiMatchTypeBestFields = "best_fields"
|
esMultiMatchTypeBestFields = "best_fields"
|
||||||
@@ -68,8 +68,8 @@ const (
|
|||||||
"label_ids": { "type": "integer", "index": true },
|
"label_ids": { "type": "integer", "index": true },
|
||||||
"no_label": { "type": "boolean", "index": true },
|
"no_label": { "type": "boolean", "index": true },
|
||||||
"milestone_id": { "type": "integer", "index": true },
|
"milestone_id": { "type": "integer", "index": true },
|
||||||
"project_id": { "type": "integer", "index": true },
|
"project_ids": { "type": "integer", "index": true },
|
||||||
"project_board_id": { "type": "integer", "index": true },
|
"no_project": { "type": "boolean", "index": true },
|
||||||
"poster_id": { "type": "integer", "index": true },
|
"poster_id": { "type": "integer", "index": true },
|
||||||
"assignee_id": { "type": "integer", "index": true },
|
"assignee_id": { "type": "integer", "index": true },
|
||||||
"mention_ids": { "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)...))
|
query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.ProjectID.Has() {
|
if options.NoProjectOnly {
|
||||||
query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value()))
|
query.Must(elastic.NewTermQuery("no_project", true))
|
||||||
}
|
} else if len(options.ProjectIDs) > 0 {
|
||||||
if options.ProjectColumnID.Has() {
|
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||||
query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value()))
|
query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.PosterID != "" {
|
if options.PosterID != "" {
|
||||||
|
|||||||
@@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
SearchOptions{
|
SearchOptions{
|
||||||
ProjectID: optional.Some(int64(1)),
|
ProjectIDs: []int64{1},
|
||||||
},
|
},
|
||||||
[]int64{5, 3, 2, 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 {
|
for _, test := range tests {
|
||||||
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
|
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, test.expectedIDs, issueIDs)
|
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) {
|
func searchIssueWithPaginator(t *testing.T) {
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ type IndexerData struct {
|
|||||||
LabelIDs []int64 `json:"label_ids"`
|
LabelIDs []int64 `json:"label_ids"`
|
||||||
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
|
NoLabel bool `json:"no_label"` // True if LabelIDs is empty
|
||||||
MilestoneID int64 `json:"milestone_id"`
|
MilestoneID int64 `json:"milestone_id"`
|
||||||
ProjectID int64 `json:"project_id"`
|
ProjectIDs []int64 `json:"project_ids"`
|
||||||
ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible
|
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"`
|
PosterID int64 `json:"poster_id"`
|
||||||
AssigneeID int64 `json:"assignee_id"`
|
AssigneeID int64 `json:"assignee_id"`
|
||||||
MentionIDs []int64 `json:"mention_ids"`
|
MentionIDs []int64 `json:"mention_ids"`
|
||||||
@@ -94,8 +95,8 @@ type SearchOptions struct {
|
|||||||
|
|
||||||
MilestoneIDs []int64 // milestones the issues have
|
MilestoneIDs []int64 // milestones the issues have
|
||||||
|
|
||||||
ProjectID optional.Option[int64] // project 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.
|
||||||
ProjectColumnID optional.Option[int64] // project column the issues belong to
|
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
|
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
|
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{
|
SearchOptions: &internal.SearchOptions{
|
||||||
Paginator: &db.ListOptions{
|
Paginator: &db.ListOptions{
|
||||||
PageSize: 5,
|
PageSize: 5,
|
||||||
},
|
},
|
||||||
ProjectID: optional.Some(int64(1)),
|
ProjectIDs: []int64{1},
|
||||||
},
|
},
|
||||||
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) {
|
||||||
assert.Len(t, result.Hits, 5)
|
assert.Len(t, result.Hits, 5)
|
||||||
for _, v := range result.Hits {
|
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 {
|
assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||||
return v.ProjectID == 1
|
return slices.Contains(v.ProjectIDs, int64(1))
|
||||||
}), result.Total)
|
}), result.Total)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no ProjectID",
|
Name: "no ProjectIDs (empty array)",
|
||||||
SearchOptions: &internal.SearchOptions{
|
SearchOptions: &internal.SearchOptions{
|
||||||
Paginator: &db.ListOptions{
|
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) {
|
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 {
|
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 {
|
// Verify we got ALL issues with no projects
|
||||||
return v.ProjectID == 0
|
expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool {
|
||||||
}), result.Total)
|
return len(v.ProjectIDs) == 0
|
||||||
},
|
})
|
||||||
},
|
assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount)
|
||||||
{
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -706,6 +672,10 @@ func generateDefaultIndexerData() []*internal.IndexerData {
|
|||||||
for i := range subscriberIDs {
|
for i := range subscriberIDs {
|
||||||
subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0
|
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{
|
data = append(data, &internal.IndexerData{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -719,8 +689,8 @@ func generateDefaultIndexerData() []*internal.IndexerData {
|
|||||||
LabelIDs: labelIDs,
|
LabelIDs: labelIDs,
|
||||||
NoLabel: len(labelIDs) == 0,
|
NoLabel: len(labelIDs) == 0,
|
||||||
MilestoneID: issueIndex % 4,
|
MilestoneID: issueIndex % 4,
|
||||||
ProjectID: issueIndex % 5,
|
ProjectIDs: projectIDs,
|
||||||
ProjectColumnID: issueIndex % 6,
|
NoProject: len(projectIDs) == 0,
|
||||||
PosterID: id%10 + 1, // PosterID should not be 0
|
PosterID: id%10 + 1, // PosterID should not be 0
|
||||||
AssigneeID: issueIndex % 10,
|
AssigneeID: issueIndex % 10,
|
||||||
MentionIDs: mentionIDs,
|
MentionIDs: mentionIDs,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
issueIndexerLatestVersion = 4
|
issueIndexerLatestVersion = 5
|
||||||
|
|
||||||
// TODO: make this configurable if necessary
|
// TODO: make this configurable if necessary
|
||||||
maxTotalHits = 10000
|
maxTotalHits = 10000
|
||||||
@@ -71,8 +71,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
|
|||||||
"label_ids",
|
"label_ids",
|
||||||
"no_label",
|
"no_label",
|
||||||
"milestone_id",
|
"milestone_id",
|
||||||
"project_id",
|
"project_ids",
|
||||||
"project_board_id",
|
"no_project",
|
||||||
"poster_id",
|
"poster_id",
|
||||||
"assignee_id",
|
"assignee_id",
|
||||||
"mention_ids",
|
"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...))
|
query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.ProjectID.Has() {
|
if options.NoProjectOnly {
|
||||||
query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value()))
|
query.And(inner_meilisearch.NewFilterEq("no_project", true))
|
||||||
}
|
} else if len(options.ProjectIDs) > 0 {
|
||||||
if options.ProjectColumnID.Has() {
|
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR"
|
||||||
query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value()))
|
query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.PosterID != "" {
|
if options.PosterID != "" {
|
||||||
|
|||||||
@@ -87,14 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var projectID int64
|
projectIDs := make([]int64, 0, len(issue.Projects))
|
||||||
if issue.Project != nil {
|
for _, project := range issue.Projects {
|
||||||
projectID = issue.Project.ID
|
projectIDs = append(projectIDs, project.ID)
|
||||||
}
|
|
||||||
|
|
||||||
projectColumnID, err := issue.ProjectColumnID(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := issue.Repo.LoadOwner(ctx); err != nil {
|
if err := issue.Repo.LoadOwner(ctx); err != nil {
|
||||||
@@ -114,8 +109,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD
|
|||||||
LabelIDs: labels,
|
LabelIDs: labels,
|
||||||
NoLabel: len(labels) == 0,
|
NoLabel: len(labels) == 0,
|
||||||
MilestoneID: issue.MilestoneID,
|
MilestoneID: issue.MilestoneID,
|
||||||
ProjectID: projectID,
|
ProjectIDs: projectIDs,
|
||||||
ProjectColumnID: projectColumnID,
|
NoProject: len(projectIDs) == 0,
|
||||||
PosterID: issue.PosterID,
|
PosterID: issue.PosterID,
|
||||||
AssigneeID: issue.AssigneeID,
|
AssigneeID: issue.AssigneeID,
|
||||||
MentionIDs: mentionIDs,
|
MentionIDs: mentionIDs,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type Issue struct {
|
|||||||
Attachments []*Attachment `json:"assets"`
|
Attachments []*Attachment `json:"assets"`
|
||||||
Labels []*Label `json:"labels"`
|
Labels []*Label `json:"labels"`
|
||||||
Milestone *Milestone `json:"milestone"`
|
Milestone *Milestone `json:"milestone"`
|
||||||
|
Projects []*Project `json:"projects"`
|
||||||
// deprecated
|
// deprecated
|
||||||
Assignee *User `json:"assignee"`
|
Assignee *User `json:"assignee"`
|
||||||
Assignees []*User `json:"assignees"`
|
Assignees []*User `json:"assignees"`
|
||||||
@@ -100,7 +101,9 @@ type CreateIssueOption struct {
|
|||||||
Milestone int64 `json:"milestone"`
|
Milestone int64 `json:"milestone"`
|
||||||
// list of label ids
|
// list of label ids
|
||||||
Labels []int64 `json:"labels"`
|
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
|
// EditIssueOption options for editing an issue
|
||||||
@@ -112,7 +115,9 @@ type EditIssueOption struct {
|
|||||||
Assignee *string `json:"assignee"`
|
Assignee *string `json:"assignee"`
|
||||||
Assignees []string `json:"assignees"`
|
Assignees []string `json:"assignees"`
|
||||||
Milestone *int64 `json:"milestone"`
|
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
|
// swagger:strfmt date-time
|
||||||
Deadline *time.Time `json:"due_date"`
|
Deadline *time.Time `json:"due_date"`
|
||||||
RemoveDeadline *bool `json:"unset_due_date"`
|
RemoveDeadline *bool `json:"unset_due_date"`
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"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
|
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
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SliceUtils struct{}
|
type SliceUtils struct{}
|
||||||
@@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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"})
|
actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
|
||||||
assert.Equal(t, "false", actual)
|
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 := template.New("test")
|
||||||
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
|
tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
|
||||||
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
|
template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
|
||||||
|
|||||||
@@ -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"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/container"
|
||||||
|
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"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.
|
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||||
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
|
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.delete": "Delete Column",
|
||||||
"repo.projects.column.deletion_desc": "Deleting a project column moves all related issues to the default column. Continue?",
|
"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.color": "Color",
|
||||||
|
"repo.projects.column": "Column",
|
||||||
"repo.projects.open": "Open",
|
"repo.projects.open": "Open",
|
||||||
"repo.projects.close": "Close",
|
"repo.projects.close": "Close",
|
||||||
"repo.projects.column.assigned_to": "Assigned to",
|
"repo.projects.column.assigned_to": "Assigned to",
|
||||||
|
|||||||
@@ -690,11 +690,11 @@ func CreateIssue(ctx *context.APIContext) {
|
|||||||
form.Labels = make([]int64, 0)
|
form.Labels = make([]int64, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil {
|
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, form.Projects); err != nil {
|
||||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
if errors.Is(err, user_model.ErrBlockedUser) {
|
||||||
ctx.APIError(http.StatusBadRequest, err)
|
|
||||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
|
||||||
ctx.APIError(http.StatusForbidden, err)
|
ctx.APIError(http.StatusForbidden, err)
|
||||||
|
} else if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
|
||||||
|
ctx.APIError(http.StatusBadRequest, err)
|
||||||
} else {
|
} else {
|
||||||
ctx.APIErrorInternal(err)
|
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
|
// Refetch from database to assign some automatic values
|
||||||
issue, err = issues_model.GetIssueByID(ctx, issue.ID)
|
issue, err = issues_model.GetIssueByID(ctx, issue.ID)
|
||||||
if err != nil {
|
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)
|
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
|
// SearchIssues searches for issues across the repositories that the user has access to
|
||||||
func SearchIssues(ctx *context.Context) {
|
func SearchIssues(ctx *context.Context) {
|
||||||
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||||||
@@ -156,10 +164,7 @@ func SearchIssues(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
projectID := optional.None[int64]()
|
includedProjectIDs := parseProjectIDsFromQuery(ctx)
|
||||||
if v := ctx.FormInt64("project"); v > 0 {
|
|
||||||
projectID = optional.Some(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// this api is also used in UI,
|
// this api is also used in UI,
|
||||||
// so the default limit is set to fit UI needs
|
// so the default limit is set to fit UI needs
|
||||||
@@ -182,7 +187,7 @@ func SearchIssues(ctx *context.Context) {
|
|||||||
IsClosed: isClosed,
|
IsClosed: isClosed,
|
||||||
IncludedAnyLabelIDs: includedAnyLabels,
|
IncludedAnyLabelIDs: includedAnyLabels,
|
||||||
MilestoneIDs: includedMilestones,
|
MilestoneIDs: includedMilestones,
|
||||||
ProjectID: projectID,
|
ProjectIDs: includedProjectIDs,
|
||||||
SortBy: issue_indexer.SortByCreatedDesc,
|
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]()
|
isPull := optional.None[bool]()
|
||||||
switch ctx.FormString("type") {
|
switch ctx.FormString("type") {
|
||||||
case "pulls":
|
case "pulls":
|
||||||
@@ -330,13 +330,20 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
|
|||||||
Page: ctx.FormInt("page"),
|
Page: ctx.FormInt("page"),
|
||||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||||
},
|
},
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||||||
IsPull: isPull,
|
IsPull: isPull,
|
||||||
IsClosed: isClosed,
|
IsClosed: isClosed,
|
||||||
ProjectID: projectID,
|
SortBy: issue_indexer.SortByCreatedDesc,
|
||||||
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 {
|
if since != 0 {
|
||||||
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
||||||
}
|
}
|
||||||
@@ -467,7 +474,7 @@ func renderMilestones(ctx *context.Context) {
|
|||||||
ctx.Data["ClosedMilestones"] = closedMilestones
|
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
|
var err error
|
||||||
viewType := ctx.FormString("type")
|
viewType := ctx.FormString("type")
|
||||||
sortType := ctx.FormString("sort")
|
sortType := ctx.FormString("sort")
|
||||||
@@ -520,7 +527,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
|||||||
RepoIDs: []int64{repo.ID},
|
RepoIDs: []int64{repo.ID},
|
||||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
MilestoneIDs: mileIDs,
|
MilestoneIDs: mileIDs,
|
||||||
ProjectID: projectID,
|
ProjectIDs: projectIDs,
|
||||||
AssigneeID: assigneeID,
|
AssigneeID: assigneeID,
|
||||||
MentionedID: mentionedID,
|
MentionedID: mentionedID,
|
||||||
PosterID: posterUserID,
|
PosterID: posterUserID,
|
||||||
@@ -529,6 +536,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
|||||||
IsPull: isPullOption,
|
IsPull: isPullOption,
|
||||||
IssueIDs: nil,
|
IssueIDs: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -600,7 +608,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
|||||||
ReviewRequestedID: reviewRequestedID,
|
ReviewRequestedID: reviewRequestedID,
|
||||||
ReviewedID: reviewedID,
|
ReviewedID: reviewedID,
|
||||||
MilestoneIDs: mileIDs,
|
MilestoneIDs: mileIDs,
|
||||||
ProjectID: projectID,
|
ProjectIDs: projectIDs,
|
||||||
IsClosed: isShowClosed,
|
IsClosed: isShowClosed,
|
||||||
IsPull: isPullOption,
|
IsPull: isPullOption,
|
||||||
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
LabelIDs: preparedLabelFilter.SelectedLabelIDs,
|
||||||
@@ -708,7 +716,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
|
|||||||
ctx.Data["ViewType"] = viewType
|
ctx.Data["ViewType"] = viewType
|
||||||
ctx.Data["SortType"] = sortType
|
ctx.Data["SortType"] = sortType
|
||||||
ctx.Data["MilestoneID"] = milestoneID
|
ctx.Data["MilestoneID"] = milestoneID
|
||||||
ctx.Data["ProjectID"] = projectID
|
ctx.Data["ProjectIDs"] = projectIDs
|
||||||
ctx.Data["AssigneeID"] = assigneeID
|
ctx.Data["AssigneeID"] = assigneeID
|
||||||
ctx.Data["PosterUsername"] = posterUsername
|
ctx.Data["PosterUsername"] = posterUsername
|
||||||
ctx.Data["Keyword"] = keyword
|
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)
|
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() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ func NewIssue(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
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 {
|
if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 {
|
||||||
ctx.Data["redirect_after_creation"] = "project"
|
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
|
// ValidateRepoMetasForNewIssue check and returns repository's meta information
|
||||||
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||||
LabelIDs, AssigneeIDs []int64
|
LabelIDs, AssigneeIDs []int64
|
||||||
MilestoneID, ProjectID int64
|
MilestoneID int64
|
||||||
|
ProjectIDs []int64
|
||||||
|
|
||||||
Reviewers []*user_model.User
|
Reviewers []*user_model.User
|
||||||
TeamReviewers []*organization.Team
|
TeamReviewers []*organization.Team
|
||||||
@@ -249,7 +251,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
|||||||
return ret
|
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 })
|
candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID })
|
||||||
if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
|
if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
@@ -265,13 +267,8 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
|||||||
}
|
}
|
||||||
pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID
|
pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID
|
||||||
|
|
||||||
allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...)
|
inputProjectIDs := ctx.FormStringInt64s("project_ids")
|
||||||
candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
|
pageMetaData.SetSelectedProjectIDs(inputProjectIDs)
|
||||||
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)
|
|
||||||
|
|
||||||
// prepare assignees
|
// prepare assignees
|
||||||
candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
|
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
|
ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
@@ -324,26 +322,17 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo
|
|||||||
// NewIssuePost response for creating new issue
|
// NewIssuePost response for creating new issue
|
||||||
func NewIssuePost(ctx *context.Context) {
|
func NewIssuePost(ctx *context.Context) {
|
||||||
form := web.GetForm(ctx).(*forms.CreateIssueForm)
|
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
|
||||||
repo = ctx.Repo.Repository
|
|
||||||
attachments []string
|
|
||||||
)
|
|
||||||
|
|
||||||
validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
|
validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
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) {
|
if !ctx.Repo.Permission.CanRead(unit.TypeProjects) {
|
||||||
// User must also be able to see the project.
|
// User must also be able to see the project.
|
||||||
ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
|
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 {
|
if setting.Attachment.Enabled {
|
||||||
attachments = form.Files
|
attachments = form.Files
|
||||||
}
|
}
|
||||||
@@ -383,7 +373,7 @@ func NewIssuePost(ctx *context.Context) {
|
|||||||
Ref: form.Ref,
|
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) {
|
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||||
ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
|
ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
|
||||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
} 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)
|
log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
|
||||||
if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 {
|
if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
|
||||||
project, err := project_model.GetProjectByID(ctx, projectID)
|
// 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 err == nil {
|
||||||
if project.Type == project_model.TypeOrganization {
|
if project.Type == project_model.TypeOrganization {
|
||||||
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
|
ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type issueSidebarProjectCardData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type issueSidebarProjectsData struct {
|
type issueSidebarProjectsData struct {
|
||||||
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
|
SelectedProjectIDs []int64
|
||||||
ProjectCards []*issueSidebarProjectCardData
|
ProjectCards []*issueSidebarProjectCardData
|
||||||
|
|
||||||
OpenProjects []*project_model.Project
|
OpenProjects []*project_model.Project
|
||||||
@@ -171,33 +171,49 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
|
|||||||
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
|
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
|
||||||
if d.Issue == nil || d.Issue.Project == nil {
|
if err := d.Issue.LoadProjects(ctx); err != nil {
|
||||||
|
ctx.ServerError("LoadProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
columns, err := d.Issue.Project.GetColumns(ctx)
|
|
||||||
|
// Load column mappings for all projects
|
||||||
|
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetProjectColumns", err)
|
ctx.ServerError("ProjectColumnMap", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
columnID, err := d.Issue.ProjectColumnID(ctx)
|
|
||||||
if err != nil {
|
// Build project cards for each project
|
||||||
ctx.ServerError("ProjectColumnID", err)
|
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
|
||||||
return
|
for _, project := range d.Issue.Projects {
|
||||||
}
|
columns, err := project.GetColumns(ctx)
|
||||||
var selectedColumn *project_model.Column
|
if err != nil {
|
||||||
for _, col := range columns {
|
ctx.ServerError("GetProjectColumns", err)
|
||||||
if col.ID == columnID {
|
return
|
||||||
selectedColumn = col
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{
|
var selectedColumn *project_model.Column
|
||||||
{
|
columnID := projectColumnMap[project.ID]
|
||||||
Project: d.Issue.Project,
|
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,
|
Columns: columns,
|
||||||
SelectedColumn: selectedColumn,
|
SelectedColumn: selectedColumn,
|
||||||
},
|
})
|
||||||
}
|
}
|
||||||
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
||||||
for _, card := range 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) {
|
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
||||||
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
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
|
// MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone
|
||||||
func MilestoneIssuesAndPulls(ctx *context.Context) {
|
func MilestoneIssuesAndPulls(ctx *context.Context) {
|
||||||
milestoneID := ctx.PathParamInt64("id")
|
milestoneID := ctx.PathParamInt64("id")
|
||||||
projectID := ctx.FormInt64("project")
|
projectIDs := parseProjectIDsFromQuery(ctx)
|
||||||
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
|
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if issues_model.IsErrMilestoneNotExist(err) {
|
if issues_model.IsErrMilestoneNotExist(err) {
|
||||||
@@ -260,7 +260,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = milestone.Name
|
ctx.Data["Title"] = milestone.Name
|
||||||
ctx.Data["Milestone"] = milestone
|
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)
|
ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||||
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
|
ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/markup/markdown"
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
@@ -447,13 +448,12 @@ func UpdateIssueProject(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectID := ctx.FormInt64("id")
|
projectIDs := ctx.FormStringInt64s("id")
|
||||||
|
var failedIssues []int64
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
if issue.Project != nil && issue.Project.ID == projectID {
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
|
|
||||||
if errors.Is(err, util.ErrPermissionDenied) {
|
if errors.Is(err, util.ErrPermissionDenied) {
|
||||||
|
failedIssues = append(failedIssues, issue.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ctx.ServerError("IssueAssignOrRemoveProject", err)
|
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()
|
ctx.JSONOK()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +481,12 @@ func UpdateIssueProjectColumn(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := issue.LoadProject(ctx); err != nil {
|
if err := issue.LoadProjects(ctx); err != nil {
|
||||||
ctx.ServerError("LoadProject", err)
|
ctx.ServerError("LoadProjects", err)
|
||||||
return
|
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
|
// it must make sure the requested column is in this issue's projects
|
||||||
var columnProject *project_model.Project
|
var columnProject *project_model.Project
|
||||||
|
|||||||
@@ -1301,7 +1301,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
|||||||
return
|
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
|
var attachments []string
|
||||||
if setting.Attachment.Enabled {
|
if setting.Attachment.Enabled {
|
||||||
@@ -1368,7 +1368,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
|||||||
AssigneeIDs: assigneeIDs,
|
AssigneeIDs: assigneeIDs,
|
||||||
Reviewers: validateRet.Reviewers,
|
Reviewers: validateRet.Reviewers,
|
||||||
TeamReviewers: validateRet.TeamReviewers,
|
TeamReviewers: validateRet.TeamReviewers,
|
||||||
ProjectID: projectID,
|
ProjectIDs: projectIDs,
|
||||||
}
|
}
|
||||||
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/base"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,11 @@ func (b *Base) FormStrings(key string) []string {
|
|||||||
return nil
|
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
|
// FormTrim returns the first value for the provided key in the form as a space trimmed string
|
||||||
func (b *Base) FormTrim(key string) string {
|
func (b *Base) FormTrim(key string) string {
|
||||||
return strings.TrimSpace(b.Req.FormValue(key))
|
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)
|
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 {
|
if err := issue.LoadAssignees(ctx); err != nil {
|
||||||
return &api.Issue{}
|
return &api.Issue{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// CreateIssueForm form for creating issue
|
||||||
type CreateIssueForm struct {
|
type CreateIssueForm struct {
|
||||||
Title string `binding:"Required;MaxSize(255)"`
|
Title string `binding:"Required;MaxSize(255)"`
|
||||||
LabelIDs string `form:"label_ids"`
|
|
||||||
AssigneeIDs string `form:"assignee_ids"`
|
AssigneeIDs string `form:"assignee_ids"`
|
||||||
ReviewerIDs string `form:"reviewer_ids"`
|
ReviewerIDs string `form:"reviewer_ids"`
|
||||||
Ref string `form:"ref"`
|
Ref string `form:"ref"`
|
||||||
MilestoneID int64
|
MilestoneID int64
|
||||||
ProjectID int64
|
|
||||||
Content string
|
Content string
|
||||||
Files []string
|
Files []string
|
||||||
AllowMaintainerEdit bool
|
AllowMaintainerEdit bool
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewIssue creates new issue with labels for repository.
|
// 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 {
|
if err := issue.LoadPoster(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -41,8 +41,9 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projectID > 0 {
|
if len(projectIDs) > 0 {
|
||||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil {
|
err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
projectColumnID, err := curIssue.ProjectColumnID(ctx)
|
projectColumnMap, err := curIssue.ProjectColumnMap(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectColumnID := projectColumnMap[column.ProjectID]
|
||||||
|
|
||||||
if projectColumnID != column.ID {
|
if projectColumnID != column.ID {
|
||||||
// add timeline to issue
|
// add timeline to issue
|
||||||
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// 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) {
|
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) {
|
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"
|
o.SortType = "project-column-sorting"
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
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
|
// for user or org projects, we need to check access permissions
|
||||||
opts := issues_model.IssuesOptions{
|
opts := issues_model.IssuesOptions{
|
||||||
ProjectID: project.ID,
|
ProjectIDs: []int64{project.ID},
|
||||||
Doer: doer,
|
Doer: doer,
|
||||||
AllPublic: doer == nil,
|
AllPublic: doer == nil,
|
||||||
Owner: project.Owner,
|
Owner: project.Owner,
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -102,28 +102,18 @@ func Test_Projects(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
column1 := project_model.Column{
|
// Get the default column created by the template (issues will be assigned here)
|
||||||
Title: "column 1",
|
defaultColumn, err := project1.MustDefaultColumn(t.Context())
|
||||||
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)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// issue 6 belongs to private repo 3 under org 3
|
// issue 6 belongs to private repo 3 under org 3
|
||||||
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
|
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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// issue 16 belongs to public repo 16 under org 3
|
// issue 16 belongs to public repo 16 under org 3
|
||||||
issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16})
|
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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
projects, err := db.Find[project_model.Project](t.Context(), project_model.SearchOptions{
|
projects, err := db.Find[project_model.Project](t.Context(), project_model.SearchOptions{
|
||||||
@@ -139,8 +129,8 @@ func Test_Projects(t *testing.T) {
|
|||||||
Doer: userAdmin,
|
Doer: userAdmin,
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
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, 1) // default column has 2 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[defaultColumn.ID], 2) // admin can visit both issues, one from public repository one from private repository
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Anonymous user", func(t *testing.T) {
|
t.Run("Anonymous user", func(t *testing.T) {
|
||||||
@@ -149,7 +139,7 @@ func Test_Projects(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, columnIssues, 1)
|
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) {
|
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.NoError(t, err)
|
||||||
assert.Len(t, columnIssues, 1)
|
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
|
AssigneeIDs []int64
|
||||||
Reviewers []*user_model.User
|
Reviewers []*user_model.User
|
||||||
TeamReviewers []*organization.Team
|
TeamReviewers []*organization.Team
|
||||||
ProjectID int64
|
ProjectIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPullRequest creates new pull request with labels for repository.
|
// NewPullRequest creates new pull request with labels for repository.
|
||||||
@@ -110,8 +110,8 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
|||||||
assigneeCommentMap[assigneeID] = comment
|
assigneeCommentMap[assigneeID] = comment
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.ProjectID > 0 && canAssignProject {
|
if len(opts.ProjectIDs) > 0 && canAssignProject {
|
||||||
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectID, 0); err != nil {
|
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil {
|
||||||
return err
|
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}}
|
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
|
||||||
|
|
||||||
@@ -12,26 +16,28 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Project -->
|
<!-- 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">
|
<span class="text">
|
||||||
{{ctx.Locale.Tr "repo.issues.filter_project"}}
|
{{ctx.Locale.Tr "repo.issues.filter_project"}}
|
||||||
</span>
|
</span>
|
||||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||||
<div class="menu">
|
<div class="menu flex-items-menu">
|
||||||
<div class="ui icon search input">
|
<div class="ui icon search input">
|
||||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_project"}}">
|
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_project"}}">
|
||||||
</div>
|
</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="item {{if $showAllProjects}}selected{{end}}" 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 $showNoProjectSelected}}selected{{end}}" href="{{QueryBuild $queryLink "project" -1}}">{{ctx.Locale.Tr "repo.issues.filter_project_none"}}</a>
|
||||||
{{if .OpenProjects}}
|
{{if .OpenProjects}}
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
{{ctx.Locale.Tr "repo.issues.new.open_projects"}}
|
{{ctx.Locale.Tr "repo.issues.new.open_projects"}}
|
||||||
</div>
|
</div>
|
||||||
{{range .OpenProjects}}
|
{{range $project := .OpenProjects}}
|
||||||
<a class="{{if $.ProjectID}}{{if eq $.ProjectID .ID}}active selected{{end}}{{end}} item tw-flex" href="{{QueryBuild $queryLink "project" .ID}}">
|
{{$toggle := SliceUtils.JoinToggleIDs $projectIDs $project.ID}}
|
||||||
{{svg .IconName 18 "tw-mr-2 tw-shrink-0"}}<span class="gt-ellipsis">{{.Title}}</span>
|
{{/* 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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -40,9 +46,11 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}
|
{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}
|
||||||
</div>
|
</div>
|
||||||
{{range .ClosedProjects}}
|
{{range $project := .ClosedProjects}}
|
||||||
<a class="{{if $.ProjectID}}{{if eq $.ProjectID .ID}}active selected{{end}}{{end}} item" href="{{QueryBuild $queryLink "project" .ID}}">
|
{{$toggle := SliceUtils.JoinToggleIDs $projectIDs $project.ID}}
|
||||||
{{svg .IconName 18 "tw-mr-2"}}{{.Title}}
|
{{/* 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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{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*/}}
|
{{/* this tmpl is quite dirty, it should not mix unrelated things together .... need to split it in the future*/}}
|
||||||
{{$allStatesLink := ""}}{{$openLink := ""}}{{$closedLink := ""}}
|
{{$allStatesLink := ""}}{{$openLink := ""}}{{$closedLink := ""}}
|
||||||
|
{{$projectIDsQuery := SliceUtils.JoinInt64 $.ProjectIDs}}
|
||||||
{{if .PageIsMilestones}}
|
{{if .PageIsMilestones}}
|
||||||
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "sort" $.SortType "state" "all"}}
|
{{$allStatesLink = QueryBuild "?" "q" $.Keyword "sort" $.SortType "state" "all"}}
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
{{$openLink = QueryBuild $allStatesLink "state" "open"}}
|
{{$openLink = QueryBuild $allStatesLink "state" "open"}}
|
||||||
{{$closedLink = QueryBuild $allStatesLink "state" "closed"}}
|
{{$closedLink = QueryBuild $allStatesLink "state" "closed"}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<input type="hidden" name="type" value="{{$.ViewType}}">
|
<input type="hidden" name="type" value="{{$.ViewType}}">
|
||||||
<input type="hidden" name="labels" value="{{$.SelectLabels}}">
|
<input type="hidden" name="labels" value="{{$.SelectLabels}}">
|
||||||
<input type="hidden" name="milestone" value="{{$.MilestoneID}}">
|
<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="assignee" value="{{$.AssigneeID}}">
|
||||||
<input type="hidden" name="poster" value="{{$.PosterUsername}}">
|
<input type="hidden" name="poster" value="{{$.PosterUsername}}">
|
||||||
<input type="hidden" name="sort" value="{{$.SortType}}">
|
<input type="hidden" name="sort" value="{{$.SortType}}">
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
{{/* project selector */}}
|
{{/* 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}}
|
{{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}}">
|
<div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
|
||||||
<a class="fixed-text muted">
|
<a class="fixed-text muted">
|
||||||
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
|
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
{{range $data.OpenProjects}}
|
{{range $data.OpenProjects}}
|
||||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
<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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -36,44 +36,37 @@
|
|||||||
{{range $data.ClosedProjects}}
|
{{range $data.ClosedProjects}}
|
||||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
<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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{{/* project cards (column selectors) */}}
|
{{/* project cards (column selectors) */}}
|
||||||
{{if not $data.ProjectCards}}
|
<div class="ui list tw-my-2 flex-relaxed-list issue-sidebar-project-cards" data-combo-list-inited="true">
|
||||||
<div class="ui list">
|
<div class="item empty-list {{if $data.ProjectCards}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</div>
|
||||||
<div class="item empty-list">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="flex-relaxed-list">
|
|
||||||
{{range $card := $data.ProjectCards}}
|
{{range $card := $data.ProjectCards}}
|
||||||
{{$selectedColumn := $card.SelectedColumn}}
|
{{$selectedColumn := $card.SelectedColumn}}
|
||||||
<div class="item sidebar-project-card">
|
{{/* only show a "project column card" if the selected column exists, otherwise only show the project title */}}
|
||||||
<div class="tw-mb-1">
|
<div class="item {{if $selectedColumn}}sidebar-project-card{{end}}">
|
||||||
<a class="suppressed flex-text-block" href="{{$card.Project.Link ctx}}">
|
<a class="suppressed flex-text-block" href="{{$card.Project.Link ctx}}">
|
||||||
{{svg $card.Project.IconName 16}}
|
{{svg $card.Project.IconName 16}} <span class="gt-ellipsis">{{$card.Project.Title}}</span>
|
||||||
<span class="gt-ellipsis">{{$card.Project.Title}}</span>
|
</a>
|
||||||
</a>
|
{{if and $selectedColumn $pageMeta.CanModifyIssueOrPull}}
|
||||||
</div>
|
|
||||||
{{if $pageMeta.CanModifyIssueOrPull}}
|
|
||||||
<div class="issue-sidebar-combo sidebar-project-column-combo" data-selection-mode="single" data-update-algo="all"
|
<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}}"
|
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}}">
|
<input class="combo-value" name="column_id" type="hidden" value="{{if $selectedColumn}}{{$selectedColumn.ID}}{{end}}">
|
||||||
<div class="ui dropdown full-width">
|
<div class="ui dropdown full-width">
|
||||||
<div class="flex-text-block tw-ml-[16px]">{{/* align with the "project" icon */}}
|
<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 $selectedColumn}}
|
||||||
{{if $card.SelectedColumn.Color}}<span class="color-icon icon-size-8" style="background-color: {{$card.SelectedColumn.Color}}"></span>{{end}}
|
{{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}}
|
{{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}}
|
{{end}}
|
||||||
{{svg "octicon-triangle-down" 14}}
|
{{svg "octicon-triangle-down" 14}}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,4 +91,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
</div>
|
||||||
|
|||||||
@@ -76,10 +76,10 @@
|
|||||||
<span class="gt-ellipsis">{{.Milestone.Name}}</span>
|
<span class="gt-ellipsis">{{.Milestone.Name}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Project}}
|
{{range $project := .Projects}}
|
||||||
<a class="project flex-text-inline tw-max-w-[300px]" href="{{.Project.Link ctx}}">
|
<a class="project flex-text-inline tw-max-w-[300px]" href="{{$project.Link ctx}}">
|
||||||
{{svg .Project.IconName 14}}
|
{{svg $project.IconName 14}}
|
||||||
<span class="gt-ellipsis">{{.Project.Title}}</span>
|
<span class="gt-ellipsis">{{$project.Title}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}}
|
{{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}}
|
||||||
|
|||||||
Generated
+86
@@ -23841,6 +23841,15 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "Milestone"
|
"x-go-name": "Milestone"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"description": "list of project ids",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"x-go-name": "Projects"
|
||||||
|
},
|
||||||
"ref": {
|
"ref": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Ref"
|
"x-go-name": "Ref"
|
||||||
@@ -25098,6 +25107,15 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "Milestone"
|
"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": {
|
"ref": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Ref"
|
"x-go-name": "Ref"
|
||||||
@@ -26622,6 +26640,13 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "PinOrder"
|
"x-go-name": "PinOrder"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Project"
|
||||||
|
},
|
||||||
|
"x-go-name": "Projects"
|
||||||
|
},
|
||||||
"pull_request": {
|
"pull_request": {
|
||||||
"$ref": "#/definitions/PullRequestMeta"
|
"$ref": "#/definitions/PullRequestMeta"
|
||||||
},
|
},
|
||||||
@@ -27974,6 +27999,67 @@
|
|||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"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": {
|
"PublicKey": {
|
||||||
"description": "PublicKey publickey is a user key to push code to repository",
|
"description": "PublicKey publickey is a user key to push code to repository",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -4102,6 +4102,15 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-go-name": "Milestone"
|
"x-go-name": "Milestone"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"description": "list of project ids",
|
||||||
|
"items": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"x-go-name": "Projects"
|
||||||
|
},
|
||||||
"ref": {
|
"ref": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Ref"
|
"x-go-name": "Ref"
|
||||||
@@ -5330,6 +5339,15 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-go-name": "Milestone"
|
"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": {
|
"ref": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Ref"
|
"x-go-name": "Ref"
|
||||||
@@ -6849,6 +6867,13 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-go-name": "PinOrder"
|
"x-go-name": "PinOrder"
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Project"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"x-go-name": "Projects"
|
||||||
|
},
|
||||||
"pull_request": {
|
"pull_request": {
|
||||||
"$ref": "#/components/schemas/PullRequestMeta"
|
"$ref": "#/components/schemas/PullRequestMeta"
|
||||||
},
|
},
|
||||||
@@ -8215,6 +8240,67 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"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": {
|
"PublicKey": {
|
||||||
"description": "PublicKey publickey is a user key to push code to repository",
|
"description": "PublicKey publickey is a user key to push code to repository",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test.describe('events', () => {
|
|||||||
await expect(badge).toBeHidden();
|
await expect(badge).toBeHidden();
|
||||||
|
|
||||||
// Create issue as another user — this generates a notification delivered via server push
|
// 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
|
// Wait for the notification badge to appear via server event
|
||||||
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
|
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
|
||||||
@@ -37,7 +37,7 @@ test.describe('events', () => {
|
|||||||
loginUser(page, name),
|
loginUser(page, name),
|
||||||
(async () => {
|
(async () => {
|
||||||
await apiCreateRepo(request, {name, headers});
|
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});
|
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {env} from 'node:process';
|
import {env} from 'node:process';
|
||||||
import {test, expect} from '@playwright/test';
|
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}) => {
|
test('assign issue to project and change column', async ({page}) => {
|
||||||
const repoName = `e2e-issue-project-${randomString(8)}`;
|
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
|
// columns created via POST because the web UI uses modals that are hard to drive
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
|
...['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.goto(`/${user}/${repoName}/issues/1`);
|
||||||
await page.locator('.sidebar-project-combo .ui.dropdown').click();
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||||
await page.locator('.sidebar-project-combo .menu a.item', {hasText: 'Kanban Board'}).click();
|
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
|
||||||
const columnCombo = page.locator('.sidebar-project-column-combo');
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
||||||
await expect(columnCombo).toBeVisible();
|
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
|
||||||
await columnCombo.locator('.ui.dropdown').click();
|
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
|
||||||
await columnCombo.locator('.menu a.item', {hasText: 'In Progress'}).click();
|
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
|
||||||
await expect(columnCombo.getByTestId('sidebar-project-column-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;
|
const owner = env.GITEA_TEST_E2E_USER;
|
||||||
await apiCreateRepo(request, {name: repoName});
|
await apiCreateRepo(request, {name: repoName});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}),
|
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
|
||||||
login(page),
|
login(page),
|
||||||
]);
|
]);
|
||||||
await page.goto(`/${owner}/${repoName}/issues/1`);
|
await page.goto(`/${owner}/${repoName}/issues/1`);
|
||||||
|
|||||||
+57
-7
@@ -47,13 +47,6 @@ export async function apiCreateRepo(requestContext: APIRequestContext, {name, au
|
|||||||
}), 'apiCreateRepo');
|
}), '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>} = {}) {
|
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`, {
|
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/start`, {
|
||||||
headers: headers || apiHeaders(),
|
headers: headers || apiHeaders(),
|
||||||
@@ -135,6 +128,63 @@ export async function apiDeleteUser(requestContext: APIRequestContext, username:
|
|||||||
}), 'apiDeleteUser');
|
}), '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) {
|
export async function loginUser(page: Page, username: string) {
|
||||||
return login(page, username, testUserPassword);
|
return login(page, username, testUserPassword);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func TestAPIIssue(t *testing.T) {
|
|||||||
t.Run("IssueContentVersion", testAPIIssueContentVersion)
|
t.Run("IssueContentVersion", testAPIIssueContentVersion)
|
||||||
t.Run("CreateIssue", testAPICreateIssue)
|
t.Run("CreateIssue", testAPICreateIssue)
|
||||||
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
|
t.Run("CreateIssueParallel", testAPICreateIssueParallel)
|
||||||
|
t.Run("IssueProjects", testAPIIssueProjects)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAPIListIssues(t *testing.T) {
|
func testAPIListIssues(t *testing.T) {
|
||||||
@@ -496,3 +497,66 @@ func testAPIIssueContentVersion(t *testing.T) {
|
|||||||
MakeRequest(t, req, http.StatusCreated)
|
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))
|
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) {
|
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
@@ -160,13 +180,13 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
|||||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
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())
|
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")
|
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())
|
assert.Equal(t, 1, columnCombo.Length())
|
||||||
|
|
||||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||||
@@ -181,16 +201,14 @@ func TestIssueSidebarProjectColumn(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.Equal(t, "3", comboVal)
|
assert.Equal(t, "3", comboVal)
|
||||||
|
|
||||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{"id": ""})
|
||||||
"id": "0",
|
|
||||||
})
|
|
||||||
sess.MakeRequest(t, req, http.StatusOK)
|
sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
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())
|
assert.Equal(t, 0, cards.Length())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,15 +311,9 @@ func TestOrgProjectFilterByMilestone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, project_model.NewProject(t.Context(), &project))
|
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
|
// 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(), issue16, user1, []int64{project.ID}))
|
||||||
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, project.ID, defaultColumnID))
|
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue17, user1, []int64{project.ID}))
|
||||||
|
|
||||||
sess := loginUser(t, "user1")
|
sess := loginUser(t, "user1")
|
||||||
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)
|
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)
|
||||||
|
|||||||
@@ -897,7 +897,7 @@ table th[data-sortt-desc] .svg {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--gap-block);
|
gap: var(--gap-block);
|
||||||
min-width: 0;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-left-right > .ui.button,
|
.flex-left-right > .ui.button,
|
||||||
|
|||||||
@@ -62,10 +62,17 @@
|
|||||||
visibility: hidden;
|
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: 1px solid var(--color-secondary);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
margin-top: var(--gap-block);
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class IssueSidebarComboList {
|
|||||||
|
|
||||||
updateUiList(changedValues: Array<string>) {
|
updateUiList(changedValues: Array<string>) {
|
||||||
if (!this.elList) return;
|
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());
|
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||||
for (const value of changedValues) {
|
for (const value of changedValues) {
|
||||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||||
@@ -139,7 +139,7 @@ export class IssueSidebarComboList {
|
|||||||
async doUpdate() {
|
async doUpdate() {
|
||||||
const changedValues = this.collectCheckedValues();
|
const changedValues = this.collectCheckedValues();
|
||||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||||
this.updateUiList(changedValues);
|
if (!this.updateUrl) this.updateUiList(changedValues);
|
||||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||||
this.initialValues = changedValues;
|
this.initialValues = changedValues;
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,9 @@ export class IssueSidebarComboList {
|
|||||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||||
elItem?.classList.add('checked');
|
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();
|
this.initialValues = this.collectCheckedValues();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user