diff --git a/.gitignore b/.gitignore index d4a677b6fa2e0..76a757864615e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ cpu.out /indexers /log /public/assets/img/avatar +/tests/e2e-output /tests/integration/gitea-integration-* /tests/integration/indexers-* /tests/*.ini diff --git a/Makefile b/Makefile index 7db7313915ead..56119380f66b3 100644 --- a/Makefile +++ b/Makefile @@ -478,7 +478,7 @@ playwright: deps-frontend @pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium firefox $(PLAYWRIGHT_FLAGS) .PHONY: test-e2e -test-e2e: playwright backend +test-e2e: playwright frontend backend @EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) .PHONY: build diff --git a/models/issues/issue.go b/models/issues/issue.go index fe5433fbb20bf..345b36a82fafc 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -59,17 +59,18 @@ type Issue struct { PosterID int64 `xorm:"INDEX"` Poster *user_model.User `xorm:"-"` OriginalAuthor string - OriginalAuthorID int64 `xorm:"index"` - Title string `xorm:"name"` - Content string `xorm:"LONGTEXT"` - RenderedContent template.HTML `xorm:"-"` - ContentVersion int `xorm:"NOT NULL DEFAULT 0"` - Labels []*Label `xorm:"-"` - isLabelsLoaded bool `xorm:"-"` - MilestoneID int64 `xorm:"INDEX"` - Milestone *Milestone `xorm:"-"` - isMilestoneLoaded bool `xorm:"-"` - Project *project_model.Project `xorm:"-"` + OriginalAuthorID int64 `xorm:"index"` + Title string `xorm:"name"` + Content string `xorm:"LONGTEXT"` + RenderedContent template.HTML `xorm:"-"` + ContentVersion int `xorm:"NOT NULL DEFAULT 0"` + Labels []*Label `xorm:"-"` + isLabelsLoaded bool `xorm:"-"` + MilestoneID int64 `xorm:"INDEX"` + Milestone *Milestone `xorm:"-"` + isMilestoneLoaded bool `xorm:"-"` + Projects []*project_model.Project `xorm:"-"` + isProjectsLoaded bool `xorm:"-"` Priority int AssigneeID int64 `xorm:"-"` Assignee *user_model.User `xorm:"-"` @@ -305,7 +306,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) { return err } - if err = issue.LoadProject(ctx); err != nil { + if err = issue.LoadProjects(ctx); err != nil { return err } @@ -355,6 +356,7 @@ func (issue *Issue) ResetAttributesLoaded() { issue.isMilestoneLoaded = false issue.isAttachmentsLoaded = false issue.isAssigneeLoaded = false + issue.isProjectsLoaded = false } // GetIsRead load the `IsRead` field of the issue diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 26b93189b8bed..da407094a749f 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -185,7 +185,7 @@ func (issues IssueList) LoadMilestones(ctx context.Context) error { func (issues IssueList) LoadProjects(ctx context.Context) error { issueIDs := issues.getIssueIDs() - projectMaps := make(map[int64]*project_model.Project, len(issues)) + issueProjectMaps := make(map[int64][]*project_model.Project, len(issues)) left := len(issueIDs) type projectWithIssueID struct { @@ -202,19 +202,21 @@ func (issues IssueList) LoadProjects(ctx context.Context) error { Select("project.*, project_issue.issue_id"). Join("INNER", "project_issue", "project.id = project_issue.project_id"). In("project_issue.issue_id", issueIDs[:limit]). + OrderBy("project_issue.issue_id ASC, project.id ASC"). Find(&projects) if err != nil { return err } for _, project := range projects { - projectMaps[project.IssueID] = project.Project + issueProjectMaps[project.IssueID] = append(issueProjectMaps[project.IssueID], project.Project) } left -= limit issueIDs = issueIDs[limit:] } for _, issue := range issues { - issue.Project = projectMaps[issue.ID] + issue.Projects = issueProjectMaps[issue.ID] + issue.isProjectsLoaded = true } return nil } diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go index e9dc412331db1..842249bad23b3 100644 --- a/models/issues/issue_list_test.go +++ b/models/issues/issue_list_test.go @@ -65,10 +65,10 @@ func TestIssueList_LoadAttributes(t *testing.T) { } if issue.ID == int64(1) { assert.Equal(t, int64(400), issue.TotalTrackedTime) - assert.NotNil(t, issue.Project) - assert.Equal(t, int64(1), issue.Project.ID) + assert.NotEmpty(t, issue.Projects) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Empty(t, issue.Projects) } } } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index f78daf77f8858..18f0f91c3819e 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -12,41 +12,38 @@ import ( "code.gitea.io/gitea/modules/util" ) -// LoadProject load the project the issue was assigned to -func (issue *Issue) LoadProject(ctx context.Context) (err error) { - if issue.Project == nil { - var p project_model.Project - has, err := db.GetEngine(ctx).Table("project"). +// LoadProjects loads all projects the issue is assigned to +func (issue *Issue) LoadProjects(ctx context.Context) (err error) { + if !issue.isProjectsLoaded { + err = db.GetEngine(ctx).Table("project"). Join("INNER", "project_issue", "project.id=project_issue.project_id"). - Where("project_issue.issue_id = ?", issue.ID).Get(&p) - if err != nil { - return err - } else if has { - issue.Project = &p + Where("project_issue.issue_id = ?", issue.ID). + OrderBy("project.id ASC"). + Find(&issue.Projects) + if err == nil { + issue.isProjectsLoaded = true } } return err } -func (issue *Issue) projectID(ctx context.Context) int64 { - var ip project_model.ProjectIssue - has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) - if err != nil || !has { - return 0 - } - return ip.ProjectID +func (issue *Issue) projectIDs(ctx context.Context) (projectIDs []int64, _ error) { + err := db.GetEngine(ctx).Table("project_issue").Where("issue_id = ?", issue.ID).Cols("project_id").Find(&projectIDs) + return projectIDs, err } -// ProjectColumnID return project column id if issue was assigned to one -func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) { - var ip project_model.ProjectIssue - has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) - if err != nil { - return 0, err - } else if !has { - return 0, nil +// ProjectColumnMap returns a map of project ID to column ID for this issue. +func (issue *Issue) ProjectColumnMap(ctx context.Context) (map[int64]int64, error) { + var projIssues []project_model.ProjectIssue + if err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Find(&projIssues); err != nil { + return nil, err + } + + result := make(map[int64]int64, len(projIssues)) + for _, projIssue := range projIssues { + result[projIssue.ProjectID] = projIssue.ProjectColumnID } - return ip.ProjectColumnID, nil + return result, nil } func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) { @@ -64,66 +61,91 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i return result, nil } -// IssueAssignOrRemoveProject changes the project associated with an issue -// If newProjectID is 0, the issue is removed from the project -func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { +// IssueAssignOrRemoveProject updates the projects associated with an issue. +// It adds projects that are in newProjectIDs but not currently assigned, +// and removes projects that are currently assigned but not in newProjectIDs. +// If newProjectIDs is empty, all projects are removed from the issue. +// When adding an issue to a project, it is placed in the project's default column. +func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error { return db.WithTx(ctx, func(ctx context.Context) error { - oldProjectID := issue.projectID(ctx) - if err := issue.LoadRepo(ctx); err != nil { return err } - // Only check if we add a new project and not remove it. - if newProjectID > 0 { - newProject, err := project_model.GetProjectByID(ctx, newProjectID) - if err != nil { + oldProjectIDs, err := issue.projectIDs(ctx) + if err != nil { + return err + } + + projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs) + issue.isProjectsLoaded = false + issue.Projects = nil + + if len(projectsToRemove) > 0 { + if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil { return err } - 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 { + 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 } - 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 { + if len(projectsToAdd) > 0 { + projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd) + if 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 { - 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 db.Insert(ctx, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: newProjectID, - ProjectColumnID: newColumnID, - Sorting: newSorting, - }) + return nil }) } diff --git a/models/issues/issue_project_multi_test.go b/models/issues/issue_project_multi_test.go new file mode 100644 index 0000000000000..b004f994b1776 --- /dev/null +++ b/models/issues/issue_project_multi_test.go @@ -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) + }) +} diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 049dcc7de8a43..f905e629e380a 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -16,6 +16,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" "xorm.io/xorm" @@ -36,8 +37,7 @@ type IssuesOptions struct { //nolint:revive // export stutter ReviewedID int64 SubscriberID int64 MilestoneIDs []int64 - ProjectID int64 - ProjectColumnID int64 + ProjectIDs []int64 IsClosed optional.Option[bool] IsPull optional.Option[bool] LabelIDs []int64 @@ -198,24 +198,17 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) { } func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { - if opts.ProjectID > 0 { // specific project - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - And("project_issue.project_id=?", opts.ProjectID) - } else if opts.ProjectID == db.NoConditionID { // show those that are in no project - sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0}))) - } - // opts.ProjectID == 0 means all projects, - // do not need to apply any condition -} - -func applyProjectColumnCondition(sess *xorm.Session, opts *IssuesOptions) { - // opts.ProjectColumnID == 0 means all project columns, + projectIDs := util.SliceRemoveAll(opts.ProjectIDs, 0) + if len(projectIDs) == 1 && projectIDs[0] == db.NoConditionID { // show those that are in no project + sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue"))) + } else if len(projectIDs) == 1 && projectIDs[0] > 0 { // single specific project + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id AND project_issue.project_id = ?", projectIDs[0]) + } else if len(projectIDs) > 1 { // multiple projects + // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" + sess.And(builder.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.In("project_id", projectIDs)))) + } + // empty projectIDs means all projects, // 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) { @@ -276,8 +269,6 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) { applyProjectCondition(sess, opts) - applyProjectColumnCondition(sess, opts) - if opts.IsPull.Has() { sess.And("issue.is_pull=?", opts.IsPull.Value()) } diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 55a90f50a19b1..b935c0fffdb2c 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -424,10 +424,10 @@ func TestIssueLoadAttributes(t *testing.T) { } if issue.ID == int64(1) { assert.Equal(t, int64(400), issue.TotalTrackedTime) - assert.NotNil(t, issue.Project) - assert.Equal(t, int64(1), issue.Project.ID) + assert.NotEmpty(t, issue.Projects) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Empty(t, issue.Projects) } } } diff --git a/models/project/project.go b/models/project/project.go index 7646c3dd719c9..7fcef430f2602 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -302,6 +302,15 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) { return p, nil } +// GetProjectsMapByIDs returns projects by a list of IDs. +func GetProjectsMapByIDs(ctx context.Context, ids []int64) (map[int64]*Project, error) { + projects := make(map[int64]*Project, len(ids)) + if len(ids) == 0 { + return projects, nil + } + return projects, db.GetEngine(ctx).In("id", ids).Find(&projects) +} + func GetProjectByIDAndOwner(ctx context.Context, id, ownerID int64) (*Project, error) { p := new(Project) has, err := db.GetEngine(ctx).ID(id).And("owner_id = ?", ownerID).Get(p) diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index 39d96cab9853c..219d4163d6aae 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -27,7 +27,7 @@ import ( const ( issueIndexerAnalyzer = "issueIndexer" issueIndexerDocType = "issueIndexerDocType" - issueIndexerLatestVersion = 5 + issueIndexerLatestVersion = 6 ) const unicodeNormalizeName = "unicodeNormalize" @@ -83,8 +83,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) { docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping) docMapping.AddFieldMappingsAt("no_label", boolFieldMapping) docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping) - docMapping.AddFieldMappingsAt("project_id", numberFieldMapping) - docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("no_project", boolFieldMapping) docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping) docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping) docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping) @@ -241,11 +241,15 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) } - if options.ProjectID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) - } - if options.ProjectColumnID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) + if options.NoProjectOnly { + queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_project")) + } else if len(options.ProjectIDs) > 0 { + var projectQueries []query.Query + for _, projectID := range options.ProjectIDs { + projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids")) + } + // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" + queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...)) } if options.PosterID != "" { diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index 380a25dc23dbc..7a66efe79147d 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/util" ) func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_model.IssuesOptions, error) { @@ -65,8 +66,7 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m ReviewRequestedID: convertID(options.ReviewRequestedID), ReviewedID: convertID(options.ReviewedID), SubscriberID: convertID(options.SubscriberID), - ProjectID: convertID(options.ProjectID), - ProjectColumnID: convertID(options.ProjectColumnID), + ProjectIDs: util.Iif(options.NoProjectOnly, []int64{db.NoConditionID}, options.ProjectIDs), IsClosed: options.IsClosed, IsPull: options.IsPull, IncludedLabelNames: nil, diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index f17724664d0fa..f4582d38dd87d 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -46,10 +46,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp searchOpt.MilestoneIDs = opts.MilestoneIDs } - if opts.ProjectID > 0 { - searchOpt.ProjectID = optional.Some(opts.ProjectID) - } else if opts.ProjectID == db.NoConditionID { // FIXME: this is inconsistent from other places - searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0) + if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID { + searchOpt.NoProjectOnly = true + } else { + searchOpt.ProjectIDs = opts.ProjectIDs } searchOpt.AssigneeID = opts.AssigneeID @@ -65,7 +65,6 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp return nil } - searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID) searchOpt.PosterID = opts.PosterID searchOpt.MentionID = convertID(opts.MentionedID) searchOpt.ReviewedID = convertID(opts.ReviewedID) diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 9d627466ef427..5c8ce570d1ef5 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -19,7 +19,7 @@ import ( ) const ( - issueIndexerLatestVersion = 2 + issueIndexerLatestVersion = 3 // multi-match-types, currently only 2 types are used // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types esMultiMatchTypeBestFields = "best_fields" @@ -68,8 +68,8 @@ const ( "label_ids": { "type": "integer", "index": true }, "no_label": { "type": "boolean", "index": true }, "milestone_id": { "type": "integer", "index": true }, - "project_id": { "type": "integer", "index": true }, - "project_board_id": { "type": "integer", "index": true }, + "project_ids": { "type": "integer", "index": true }, + "no_project": { "type": "boolean", "index": true }, "poster_id": { "type": "integer", "index": true }, "assignee_id": { "type": "integer", "index": true }, "mention_ids": { "type": "integer", "index": true }, @@ -204,11 +204,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) } - if options.ProjectID.Has() { - query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) - } - if options.ProjectColumnID.Has() { - query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) + if options.NoProjectOnly { + query.Must(elastic.NewTermQuery("no_project", true)) + } else if len(options.ProjectIDs) > 0 { + // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" + query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...)) } if options.PosterID != "" { diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 3e38ac49b719c..a67e96c0a28b3 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -416,28 +416,42 @@ func searchIssueInProject(t *testing.T) { }{ { SearchOptions{ - ProjectID: optional.Some(int64(1)), + ProjectIDs: []int64{1}, }, []int64{5, 3, 2, 1}, }, - { - SearchOptions{ - ProjectColumnID: optional.Some(int64(1)), - }, - []int64{1}, - }, - { - SearchOptions{ - ProjectColumnID: optional.Some(int64(0)), // issue with in default column - }, - []int64{2}, - }, } for _, test := range tests { issueIDs, _, err := SearchIssues(t.Context(), &test.opts) require.NoError(t, err) assert.Equal(t, test.expectedIDs, issueIDs) } + + // Test filtering for issues with no project assigned using dynamic validation + t.Run("no project assigned", func(t *testing.T) { + issueIDs, total, err := SearchIssues(t.Context(), &SearchOptions{ + ProjectIDs: []int64{db.NoConditionID}, + }) + require.NoError(t, err) + assert.NotEmpty(t, issueIDs) + assert.Equal(t, total, int64(len(issueIDs))) + + // Verify each returned issue actually has no project + for _, issueID := range issueIDs { + issue, err := issues.GetIssueByID(t.Context(), issueID) + require.NoError(t, err) + err = issue.LoadProjects(t.Context()) + require.NoError(t, err) + assert.Empty(t, issue.Projects, "Issue %d should have no projects", issueID) + } + + // Count total issues with no project to verify we got them all + allIssues, err := issues.Issues(t.Context(), &issues.IssuesOptions{ + ProjectIDs: []int64{db.NoConditionID}, + }) + require.NoError(t, err) + assert.Len(t, issueIDs, len(allIssues), "Should return all issues with no project") + }) } func searchIssueWithPaginator(t *testing.T) { diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index 0d4f0f727d53c..84979a8e645a0 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -30,8 +30,9 @@ type IndexerData struct { LabelIDs []int64 `json:"label_ids"` NoLabel bool `json:"no_label"` // True if LabelIDs is empty MilestoneID int64 `json:"milestone_id"` - ProjectID int64 `json:"project_id"` - ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible + ProjectIDs []int64 `json:"project_ids"` + NoProject bool `json:"no_project"` // True if ProjectIDs is empty + ProjectColumnMap map[int64]int64 `json:"project_column_map,omitempty"` // Maps project ID to column ID for each project the issue is in PosterID int64 `json:"poster_id"` AssigneeID int64 `json:"assignee_id"` MentionIDs []int64 `json:"mention_ids"` @@ -94,8 +95,8 @@ type SearchOptions struct { MilestoneIDs []int64 // milestones the issues have - ProjectID optional.Option[int64] // project the issues belong to - ProjectColumnID optional.Option[int64] // project column the issues belong to + ProjectIDs []int64 // project the issues belong to. FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. + NoProjectOnly bool // if the issues have no project, if true, ProjectIDs will be ignored PosterID string // poster of the issues, "(none)" or "(any)" or a user ID AssigneeID string // assignee of the issues, "(none)" or "(any)" or a user ID diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 7aebbbcd58e01..18f49d6d4d848 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -301,75 +301,41 @@ var cases = []*testIndexerCase{ }, }, { - Name: "ProjectID", + Name: "ProjectIDs", SearchOptions: &internal.SearchOptions{ Paginator: &db.ListOptions{ PageSize: 5, }, - ProjectID: optional.Some(int64(1)), + ProjectIDs: []int64{1}, }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Len(t, result.Hits, 5) for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].ProjectID) + assert.Contains(t, data[v.ID].ProjectIDs, int64(1)) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 1 + return slices.Contains(v.ProjectIDs, int64(1)) }), result.Total) }, }, { - Name: "no ProjectID", + Name: "no ProjectIDs (empty array)", SearchOptions: &internal.SearchOptions{ Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectID: 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].ProjectID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 0 - }), result.Total) - }, - }, - { - Name: "ProjectColumnID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectColumnID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Len(t, result.Hits, 5) - for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].ProjectColumnID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectColumnID == 1 - }), result.Total) - }, - }, - { - Name: "no ProjectColumnID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, + PageSize: 50, }, - ProjectColumnID: optional.Some(int64(0)), + NoProjectOnly: true, }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Len(t, result.Hits, 5) + // Verify only issues with no projects are returned for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].ProjectColumnID) + assert.Empty(t, data[v.ID].ProjectIDs, "Issue %d should have no projects", v.ID) } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectColumnID == 0 - }), result.Total) + // Verify we got ALL issues with no projects + expectedCount := countIndexerData(data, func(v *internal.IndexerData) bool { + return len(v.ProjectIDs) == 0 + }) + assert.Equal(t, expectedCount, result.Total, "Should return all %d issues with no project", expectedCount) }, }, { @@ -706,6 +672,10 @@ func generateDefaultIndexerData() []*internal.IndexerData { for i := range subscriberIDs { subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0 } + projectIDs := make([]int64, id%5) + for i := range projectIDs { + projectIDs[i] = int64(i) + 1 // projectID should not be 0 + } data = append(data, &internal.IndexerData{ ID: id, @@ -719,8 +689,8 @@ func generateDefaultIndexerData() []*internal.IndexerData { LabelIDs: labelIDs, NoLabel: len(labelIDs) == 0, MilestoneID: issueIndex % 4, - ProjectID: issueIndex % 5, - ProjectColumnID: issueIndex % 6, + ProjectIDs: projectIDs, + NoProject: len(projectIDs) == 0, PosterID: id%10 + 1, // PosterID should not be 0 AssigneeID: issueIndex % 10, MentionIDs: mentionIDs, diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 5715cf4794712..6ac6d239c80e1 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -20,7 +20,7 @@ import ( ) const ( - issueIndexerLatestVersion = 4 + issueIndexerLatestVersion = 5 // TODO: make this configurable if necessary maxTotalHits = 10000 @@ -71,8 +71,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer { "label_ids", "no_label", "milestone_id", - "project_id", - "project_board_id", + "project_ids", + "no_project", "poster_id", "assignee_id", "mention_ids", @@ -182,11 +182,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) } - if options.ProjectID.Has() { - query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) - } - if options.ProjectColumnID.Has() { - query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) + if options.NoProjectOnly { + query.And(inner_meilisearch.NewFilterEq("no_project", true)) + } else if len(options.ProjectIDs) > 0 { + // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: this logic is not right, it should use "AND" but not "OR" + query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...)) } if options.PosterID != "" { diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go index 7647be58e89f2..fafe9b8bbd8e0 100644 --- a/modules/indexer/issues/util.go +++ b/modules/indexer/issues/util.go @@ -87,14 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD return nil, false, err } - var projectID int64 - if issue.Project != nil { - projectID = issue.Project.ID - } - - projectColumnID, err := issue.ProjectColumnID(ctx) - if err != nil { - return nil, false, err + projectIDs := make([]int64, 0, len(issue.Projects)) + for _, project := range issue.Projects { + projectIDs = append(projectIDs, project.ID) } if err := issue.Repo.LoadOwner(ctx); err != nil { @@ -114,8 +109,8 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD LabelIDs: labels, NoLabel: len(labels) == 0, MilestoneID: issue.MilestoneID, - ProjectID: projectID, - ProjectColumnID: projectColumnID, + ProjectIDs: projectIDs, + NoProject: len(projectIDs) == 0, PosterID: issue.PosterID, AssigneeID: issue.AssigneeID, MentionIDs: mentionIDs, diff --git a/modules/structs/issue.go b/modules/structs/issue.go index fd29727a4365e..f108cf3d0a678 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -60,6 +60,7 @@ type Issue struct { Attachments []*Attachment `json:"assets"` Labels []*Label `json:"labels"` Milestone *Milestone `json:"milestone"` + Projects []*Project `json:"projects"` // deprecated Assignee *User `json:"assignee"` Assignees []*User `json:"assignees"` @@ -100,7 +101,9 @@ type CreateIssueOption struct { Milestone int64 `json:"milestone"` // list of label ids Labels []int64 `json:"labels"` - Closed bool `json:"closed"` + // list of project ids + Projects []int64 `json:"projects"` + Closed bool `json:"closed"` } // EditIssueOption options for editing an issue @@ -112,7 +115,9 @@ type EditIssueOption struct { Assignee *string `json:"assignee"` Assignees []string `json:"assignees"` Milestone *int64 `json:"milestone"` - State *string `json:"state"` + // list of project ids to set (replaces existing projects) + Projects *[]int64 `json:"projects"` + State *string `json:"state"` // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` diff --git a/modules/structs/project.go b/modules/structs/project.go new file mode 100644 index 0000000000000..5feb122767bb9 --- /dev/null +++ b/modules/structs/project.go @@ -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"` +} diff --git a/modules/templates/page.go b/modules/templates/page.go index 32e52bb68e4b7..475cb0d678ef4 100644 --- a/modules/templates/page.go +++ b/modules/templates/page.go @@ -5,6 +5,7 @@ package templates import ( "context" + "fmt" "html/template" "io" "net/http" @@ -36,7 +37,11 @@ func (r *pageRenderer) funcMapDummy() template.FuncMap { } func (r *pageRenderer) TemplateLookup(tmpl string, templateCtx context.Context) (TemplateExecutor, error) { //nolint:revive // we don't use ctx, only pass it to the template executor - return r.tmplRenderer.Templates().Executor(tmpl, r.funcMap(templateCtx)) + tmpls := r.tmplRenderer.Templates() + if tmpls == nil { + return nil, fmt.Errorf("no templates defined for %s", tmpl) + } + return tmpls.Executor(tmpl, r.funcMap(templateCtx)) } func (r *pageRenderer) HTML(w io.Writer, status int, tplName TplName, data any, templateCtx context.Context) error { //nolint:revive // we don't use ctx, only pass it to the template executor diff --git a/modules/templates/util_slice.go b/modules/templates/util_slice.go index a3318cc11a435..e74b308471a10 100644 --- a/modules/templates/util_slice.go +++ b/modules/templates/util_slice.go @@ -6,6 +6,11 @@ package templates import ( "fmt" "reflect" + "slices" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/util" ) type SliceUtils struct{} @@ -33,3 +38,29 @@ func (su *SliceUtils) Contains(s, v any) bool { } return false } + +// JoinInt64 joins a slice of int64 values into a comma-separated string. +func (su *SliceUtils) JoinInt64(values []int64) string { + if len(values) == 0 { + return "" + } + strs := make([]string, len(values)) + for i, v := range values { + strs[i] = strconv.FormatInt(v, 10) + } + return strings.Join(strs, ",") +} + +func (su *SliceUtils) JoinToggleIDs(values []int64, target int64) (ret struct { + IsIncluded bool + ToggledIDs string +}, +) { + ret.IsIncluded = slices.Contains(values, target) + if ret.IsIncluded { + ret.ToggledIDs = su.JoinInt64(util.SliceRemoveAll(slices.Clone(values), target)) + } else { + ret.ToggledIDs = su.JoinInt64(append(values, target)) + } + return ret +} diff --git a/modules/templates/util_test.go b/modules/templates/util_test.go index a6448a6ff238b..88b05485af05f 100644 --- a/modules/templates/util_test.go +++ b/modules/templates/util_test.go @@ -70,6 +70,16 @@ func TestUtils(t *testing.T) { actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"}) assert.Equal(t, "false", actual) + // Test JoinInt64 + actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{1, 2, 3}}) + assert.Equal(t, "1,2,3", actual) + + actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{}}) + assert.Empty(t, actual) + + actual = execTmpl("{{SliceUtils.JoinInt64 .Values}}", map[string]any{"Values": []int64{42}}) + assert.Equal(t, "42", actual) + tmpl := template.New("test") tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils}) template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}")) diff --git a/modules/util/diff_slice_test.go b/modules/util/diff_slice_test.go new file mode 100644 index 0000000000000..cd61fb571b12b --- /dev/null +++ b/modules/util/diff_slice_test.go @@ -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) + }) +} diff --git a/modules/util/util.go b/modules/util/util.go index 04d0fb584d5d3..17733736be35a 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -15,6 +15,8 @@ import ( "strings" "sync" + "code.gitea.io/gitea/modules/container" + "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -291,3 +293,21 @@ func NormalizeStringEOL(input string) string { // Other than this, we should respect the original content, even leading or trailing spaces. return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input))) } + +func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) { + oldSet := container.SetOf(oldSlice...) + newSet := container.SetOf(newSlice...) + + addedSet, removedSet := container.Set[T]{}, container.Set[T]{} + for _, v := range newSlice { + if !oldSet.Contains(v) && addedSet.Add(v) { + added = append(added, v) + } + } + for _, v := range oldSlice { + if !newSet.Contains(v) && removedSet.Add(v) { + removed = append(removed, v) + } + } + return added, removed +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 2ee983ae18397..9c6903edc6cb2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1385,6 +1385,7 @@ "repo.projects.column.delete": "Delete Column", "repo.projects.column.deletion_desc": "Deleting a project column moves all related issues to the default column. Continue?", "repo.projects.column.color": "Color", + "repo.projects.column": "Column", "repo.projects.open": "Open", "repo.projects.close": "Close", "repo.projects.column.assigned_to": "Assigned to", diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index f8c1c67f067fd..39ca7fb77e94c 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -690,11 +690,11 @@ func CreateIssue(ctx *context.APIContext) { form.Labels = make([]int64, 0) } - if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { - ctx.APIError(http.StatusBadRequest, err) - } else if errors.Is(err, user_model.ErrBlockedUser) { + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, form.Projects); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { ctx.APIError(http.StatusForbidden, err) + } else if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusBadRequest, err) } else { ctx.APIErrorInternal(err) } @@ -913,6 +913,18 @@ func EditIssue(ctx *context.APIContext) { } } + // Update projects if provided + if canWrite && form.Projects != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, *form.Projects); err != nil { + if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusBadRequest, err) + } else { + ctx.APIErrorInternal(err) + } + return + } + } + // Refetch from database to assign some automatic values issue, err = issues_model.GetIssueByID(ctx, issue.ID) if err != nil { diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 60d7a4f24ddc9..fc891ac8e44ca 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -38,6 +38,14 @@ func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Reposit ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo) } +// parseProjectIDsFromQuery parses the comma-separated `project` (preferred) or `projects` +// query parameter into a slice of int64 IDs. +func parseProjectIDsFromQuery(ctx *context.Context) []int64 { + // FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet + // Although here parses the project parameter as a slice, the "search" logic is wrong + return ctx.FormStringInt64s("project") +} + // SearchIssues searches for issues across the repositories that the user has access to func SearchIssues(ctx *context.Context) { before, since, err := context.GetQueryBeforeSince(ctx.Base) @@ -156,10 +164,7 @@ func SearchIssues(ctx *context.Context) { } } - projectID := optional.None[int64]() - if v := ctx.FormInt64("project"); v > 0 { - projectID = optional.Some(v) - } + includedProjectIDs := parseProjectIDsFromQuery(ctx) // this api is also used in UI, // so the default limit is set to fit UI needs @@ -182,7 +187,7 @@ func SearchIssues(ctx *context.Context) { IsClosed: isClosed, IncludedAnyLabelIDs: includedAnyLabels, MilestoneIDs: includedMilestones, - ProjectID: projectID, + ProjectIDs: includedProjectIDs, SortBy: issue_indexer.SortByCreatedDesc, } @@ -298,11 +303,6 @@ func SearchRepoIssuesJSON(ctx *context.Context) { } } - projectID := optional.None[int64]() - if v := ctx.FormInt64("project"); v > 0 { - projectID = optional.Some(v) - } - isPull := optional.None[bool]() switch ctx.FormString("type") { case "pulls": @@ -330,13 +330,20 @@ func SearchRepoIssuesJSON(ctx *context.Context) { Page: ctx.FormInt("page"), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, - Keyword: keyword, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsPull: isPull, - IsClosed: isClosed, - ProjectID: projectID, - SortBy: issue_indexer.SortByCreatedDesc, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + SortBy: issue_indexer.SortByCreatedDesc, } + + projectIDs := parseProjectIDsFromQuery(ctx) + if len(projectIDs) == 1 && projectIDs[0] == -1 { + searchOpt.NoProjectOnly = true + } else if len(projectIDs) > 0 { + searchOpt.ProjectIDs = projectIDs + } + if since != 0 { searchOpt.UpdatedAfterUnix = optional.Some(since) } @@ -467,7 +474,7 @@ func renderMilestones(ctx *context.Context) { ctx.Data["ClosedMilestones"] = closedMilestones } -func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { +func prepareIssueFilterAndList(ctx *context.Context, milestoneID int64, projectIDs []int64, isPullOption optional.Option[bool]) { var err error viewType := ctx.FormString("type") sortType := ctx.FormString("sort") @@ -520,7 +527,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 RepoIDs: []int64{repo.ID}, LabelIDs: preparedLabelFilter.SelectedLabelIDs, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: projectIDs, AssigneeID: assigneeID, MentionedID: mentionedID, PosterID: posterUserID, @@ -529,6 +536,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 IsPull: isPullOption, IssueIDs: nil, } + if keyword != "" { keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts)) if err != nil { @@ -600,7 +608,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 ReviewRequestedID: reviewRequestedID, ReviewedID: reviewedID, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: projectIDs, IsClosed: isShowClosed, IsPull: isPullOption, LabelIDs: preparedLabelFilter.SelectedLabelIDs, @@ -708,7 +716,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType ctx.Data["MilestoneID"] = milestoneID - ctx.Data["ProjectID"] = projectID + ctx.Data["ProjectIDs"] = projectIDs ctx.Data["AssigneeID"] = assigneeID ctx.Data["PosterUsername"] = posterUsername ctx.Data["Keyword"] = keyword @@ -749,7 +757,9 @@ func Issues(ctx *context.Context) { ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) } - prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) + projectIDs := parseProjectIDsFromQuery(ctx) + + prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), projectIDs, optional.Some(isPullList)) if ctx.Written() { return } diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 861709d2ffb88..d442f2804f22f 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -121,7 +121,8 @@ func NewIssue(ctx *context.Context) { } pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") - pageMetaData.ProjectsData.SelectedProjectIDs, _ = base.StringsToInt64s(strings.Split(ctx.FormString("project"), ",")) + + pageMetaData.SetSelectedProjectIDs(parseProjectIDsFromQuery(ctx)) if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 { ctx.Data["redirect_after_creation"] = "project" } @@ -237,8 +238,9 @@ func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(Item // ValidateRepoMetasForNewIssue check and returns repository's meta information func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { - LabelIDs, AssigneeIDs []int64 - MilestoneID, ProjectID int64 + LabelIDs, AssigneeIDs []int64 + MilestoneID int64 + ProjectIDs []int64 Reviewers []*user_model.User TeamReviewers []*organization.Team @@ -249,7 +251,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo return ret } - inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + inputLabelIDs := ctx.FormStringInt64s("label_ids") candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID }) if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) { ctx.NotFound(nil) @@ -265,13 +267,8 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo } pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID - allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...) - candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) - if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) { - ctx.NotFound(nil) - return ret - } - pageMetaData.ProjectsData.SelectedProjectIDs = util.Iif(form.ProjectID > 0, []int64{form.ProjectID}, nil) + inputProjectIDs := ctx.FormStringInt64s("project_ids") + pageMetaData.SetSelectedProjectIDs(inputProjectIDs) // prepare assignees candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) @@ -316,7 +313,8 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo } } - ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID + // Return only the validated IDs. + ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectIDs = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, inputProjectIDs ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers return ret } @@ -324,26 +322,17 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo // NewIssuePost response for creating new issue func NewIssuePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateIssueForm) - ctx.Data["Title"] = ctx.Tr("repo.issues.new") - ctx.Data["PageIsIssueList"] = true - ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) - ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes - ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled - upload.AddUploadContext(ctx, "comment") - var ( - repo = ctx.Repo.Repository - attachments []string - ) + repo := ctx.Repo.Repository validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false) if ctx.Written() { return } - labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID + labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs - if projectID > 0 { + if len(projectIDs) > 0 { if !ctx.Repo.Permission.CanRead(unit.TypeProjects) { // User must also be able to see the project. ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects") @@ -351,6 +340,7 @@ func NewIssuePost(ctx *context.Context) { } } + var attachments []string if setting.Attachment.Enabled { attachments = form.Files } @@ -383,7 +373,7 @@ func NewIssuePost(ctx *context.Context) { Ref: form.Ref, } - if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) } else if errors.Is(err, user_model.ErrBlockedUser) { @@ -395,8 +385,9 @@ func NewIssuePost(ctx *context.Context) { } log.Trace("Issue created: %d/%d", repo.ID, issue.ID) - if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { - project, err := project_model.GetProjectByID(ctx, projectID) + if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 { + // When issue is in multiple projects, redirect to first project from form order. + project, err := project_model.GetProjectByID(ctx, projectIDs[0]) if err == nil { if project.Type == project_model.TypeOrganization { ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID)) diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 9c7ac65a1f940..428171dd0ee64 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -40,7 +40,7 @@ type issueSidebarProjectCardData struct { } type issueSidebarProjectsData struct { - SelectedProjectIDs []int64 // TODO: support multiple projects in the future + SelectedProjectIDs []int64 ProjectCards []*issueSidebarProjectCardData OpenProjects []*project_model.Project @@ -171,33 +171,49 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees } -func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { - if d.Issue == nil || d.Issue.Project == nil { - return - } - columns, err := d.Issue.Project.GetColumns(ctx) - if err != nil { - ctx.ServerError("GetProjectColumns", err) +func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) { + if err := d.Issue.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) return } - columnID, err := d.Issue.ProjectColumnID(ctx) + + // Load column mappings for all projects + projectColumnMap, err := d.Issue.ProjectColumnMap(ctx) if err != nil { - ctx.ServerError("ProjectColumnID", err) + ctx.ServerError("ProjectColumnMap", err) return } - var selectedColumn *project_model.Column - for _, col := range columns { - if col.ID == columnID { - selectedColumn = col - break + + // Build project cards for each project + d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects)) + for _, project := range d.Issue.Projects { + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return } - } - d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{ - { - Project: d.Issue.Project, + + var selectedColumn *project_model.Column + columnID := projectColumnMap[project.ID] + for _, col := range columns { + if col.ID == columnID { + selectedColumn = col + break + } + } + + if selectedColumn == nil { + selectedColumn, err = project.MustDefaultColumn(ctx) + if err != nil { + ctx.ServerError("MustDefaultColumn", err) + return + } + } + d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{ + Project: project, Columns: columns, SelectedColumn: selectedColumn, - }, + }) } d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) for _, card := range d.ProjectsData.ProjectCards { @@ -205,6 +221,29 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { } } +func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { + if d.Issue == nil { + return + } + d.retrieveProjectCardsForExistingIssue(ctx) +} + +func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) { + allProjects := map[int64]*project_model.Project{} + for _, p := range d.ProjectsData.OpenProjects { + allProjects[p.ID] = p + } + for _, p := range d.ProjectsData.ClosedProjects { + allProjects[p.ID] = p + } + for _, id := range ids { + if project, ok := allProjects[id]; ok { + d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project}) + } + } + d.ProjectsData.SelectedProjectIDs = ids +} + func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index 5e23c1c413cfe..759b9910d8471 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -238,7 +238,7 @@ func DeleteMilestone(ctx *context.Context) { // MilestoneIssuesAndPulls lists all the issues and pull requests of the milestone func MilestoneIssuesAndPulls(ctx *context.Context) { milestoneID := ctx.PathParamInt64("id") - projectID := ctx.FormInt64("project") + projectIDs := parseProjectIDsFromQuery(ctx) milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { @@ -260,7 +260,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone - prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]()) + prepareIssueFilterAndList(ctx, milestoneID, projectIDs, optional.None[bool]()) ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0 diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a94051f2980b9..8690e754637ec 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -17,6 +17,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" @@ -447,13 +448,12 @@ func UpdateIssueProject(ctx *context.Context) { return } - projectID := ctx.FormInt64("id") + projectIDs := ctx.FormStringInt64s("id") + var failedIssues []int64 for _, issue := range issues { - if issue.Project != nil && issue.Project.ID == projectID { - continue - } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs); err != nil { if errors.Is(err, util.ErrPermissionDenied) { + failedIssues = append(failedIssues, issue.ID) continue } ctx.ServerError("IssueAssignOrRemoveProject", err) @@ -461,6 +461,10 @@ func UpdateIssueProject(ctx *context.Context) { } } + if len(failedIssues) > 0 { + log.Warn("Failed to assign projects to %d issues due to permission denied: %v", len(failedIssues), failedIssues) + } + ctx.JSONOK() } @@ -477,12 +481,12 @@ func UpdateIssueProjectColumn(ctx *context.Context) { return } - if err := issue.LoadProject(ctx); err != nil { - ctx.ServerError("LoadProject", err) + if err := issue.LoadProjects(ctx); err != nil { + ctx.ServerError("LoadProjects", err) return } - issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future + issueProjects := issue.Projects // it must make sure the requested column is in this issue's projects var columnProject *project_model.Project diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 4e30c385b9154..7406c1d12282f 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1301,7 +1301,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID + labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs var attachments []string if setting.Attachment.Enabled { @@ -1368,7 +1368,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { AssigneeIDs: assigneeIDs, Reviewers: validateRet.Reviewers, TeamReviewers: validateRet.TeamReviewers, - ProjectID: projectID, + ProjectIDs: projectIDs, } if err := pull_service.NewPullRequest(ctx, prOpts); err != nil { switch { diff --git a/services/context/base_form.go b/services/context/base_form.go index 81fd7cd32874a..b734ab199a551 100644 --- a/services/context/base_form.go +++ b/services/context/base_form.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/util" ) @@ -35,6 +36,11 @@ func (b *Base) FormStrings(key string) []string { return nil } +func (b *Base) FormStringInt64s(key string) []int64 { + vals, _ := base.StringsToInt64s(strings.Split(b.FormString(key), ",")) + return vals +} + // FormTrim returns the first value for the provided key in the form as a space trimmed string func (b *Base) FormTrim(key string) string { return strings.TrimSpace(b.Req.FormValue(key)) diff --git a/services/convert/issue.go b/services/convert/issue.go index 61f11d8f191bf..8e3adaa82dff3 100644 --- a/services/convert/issue.go +++ b/services/convert/issue.go @@ -95,6 +95,13 @@ func toIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Iss apiIssue.Milestone = ToAPIMilestone(issue.Milestone) } + if err := issue.LoadProjects(ctx); err != nil { + return &api.Issue{} + } + if len(issue.Projects) > 0 { + apiIssue.Projects = ToAPIProjectList(issue.Projects) + } + if err := issue.LoadAssignees(ctx); err != nil { return &api.Issue{} } diff --git a/services/convert/project.go b/services/convert/project.go new file mode 100644 index 0000000000000..b66de746cad21 --- /dev/null +++ b/services/convert/project.go @@ -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 +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 09b9b2690c484..3135026e36c27 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -412,12 +412,10 @@ func (f *NewPackagistHookForm) Validate(req *http.Request, errs binding.Errors) // CreateIssueForm form for creating issue type CreateIssueForm struct { Title string `binding:"Required;MaxSize(255)"` - LabelIDs string `form:"label_ids"` AssigneeIDs string `form:"assignee_ids"` ReviewerIDs string `form:"reviewer_ids"` Ref string `form:"ref"` MilestoneID int64 - ProjectID int64 Content string Files []string AllowMaintainerEdit bool diff --git a/services/issue/issue.go b/services/issue/issue.go index 5b57b2453eae1..2bece1c7bb869 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -23,7 +23,7 @@ import ( ) // NewIssue creates new issue with labels for repository. -func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error { +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs, projectIDs []int64) error { if err := issue.LoadPoster(ctx); err != nil { return err } @@ -41,8 +41,9 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo return err } } - if projectID > 0 { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil { + if len(projectIDs) > 0 { + err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs) + if err != nil { return err } } diff --git a/services/projects/issue.go b/services/projects/issue.go index ece9910cd23ec..5c691a95eb878 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -59,11 +59,13 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum continue } - projectColumnID, err := curIssue.ProjectColumnID(ctx) + projectColumnMap, err := curIssue.ProjectColumnMap(ctx) if err != nil { return err } + projectColumnID := projectColumnMap[column.ProjectID] + if projectColumnID != column.ID { // add timeline to issue if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ @@ -80,7 +82,16 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum } } - _, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID) + // Update the column and sorting for this specific issue in this specific project. + // IMPORTANT: The WHERE clause must include both issue_id AND project_id to ensure + // that moving an issue's column in one project doesn't affect its column in other + // projects when the issue is assigned to multiple projects. + _, err = db.GetEngine(ctx).Table("project_issue"). + Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID). + Update(map[string]any{ + "project_board_id": column.ID, + "sorting": sorting, + }) if err != nil { return err } @@ -117,7 +128,7 @@ func LoadIssuesAssigneesForProject(ctx context.Context, issuesMap map[int64]issu // LoadIssuesFromProject load issues assigned to each project column inside the given project func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { - o.ProjectID = project.ID + o.ProjectIDs = []int64{project.ID} o.SortType = "project-column-sorting" })) if err != nil { @@ -211,10 +222,10 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj // for user or org projects, we need to check access permissions opts := issues_model.IssuesOptions{ - ProjectID: project.ID, - Doer: doer, - AllPublic: doer == nil, - Owner: project.Owner, + ProjectIDs: []int64{project.ID}, + Doer: doer, + AllPublic: doer == nil, + Owner: project.Owner, } var err error diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go index 17d0fef2e6e4f..ec31b22b906c7 100644 --- a/services/projects/issue_test.go +++ b/services/projects/issue_test.go @@ -102,28 +102,18 @@ func Test_Projects(t *testing.T) { assert.NoError(t, err) }() - column1 := project_model.Column{ - Title: "column 1", - ProjectID: project1.ID, - } - err = project_model.NewColumn(t.Context(), &column1) - assert.NoError(t, err) - - column2 := project_model.Column{ - Title: "column 2", - ProjectID: project1.ID, - } - err = project_model.NewColumn(t.Context(), &column2) + // Get the default column created by the template (issues will be assigned here) + defaultColumn, err := project1.MustDefaultColumn(t.Context()) assert.NoError(t, err) // issue 6 belongs to private repo 3 under org 3 issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6}) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue6, user2, project1.ID, column1.ID) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue6, user2, []int64{project1.ID}) assert.NoError(t, err) // issue 16 belongs to public repo 16 under org 3 issue16 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 16}) - err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user2, project1.ID, column1.ID) + err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue16, user2, []int64{project1.ID}) assert.NoError(t, err) projects, err := db.Find[project_model.Project](t.Context(), project_model.SearchOptions{ @@ -139,8 +129,8 @@ func Test_Projects(t *testing.T) { Doer: userAdmin, }) assert.NoError(t, err) - assert.Len(t, columnIssues, 1) // column1 has 2 issues, 6 will not contains here because 0 issues - assert.Len(t, columnIssues[column1.ID], 2) // user2 can visit both issues, one from public repository one from private repository + assert.Len(t, columnIssues, 1) // default column has 2 issues + assert.Len(t, columnIssues[defaultColumn.ID], 2) // admin can visit both issues, one from public repository one from private repository }) t.Run("Anonymous user", func(t *testing.T) { @@ -149,7 +139,7 @@ func Test_Projects(t *testing.T) { }) assert.NoError(t, err) assert.Len(t, columnIssues, 1) - assert.Len(t, columnIssues[column1.ID], 1) // anonymous user can only visit public repo issues + assert.Len(t, columnIssues[defaultColumn.ID], 1) // anonymous user can only visit public repo issues }) t.Run("Authenticated user with no permission to the private repo", func(t *testing.T) { @@ -159,7 +149,7 @@ func Test_Projects(t *testing.T) { }) assert.NoError(t, err) assert.Len(t, columnIssues, 1) - assert.Len(t, columnIssues[column1.ID], 1) // user4 can only visit public repo issues + assert.Len(t, columnIssues[defaultColumn.ID], 1) // user2 can only visit public repo issues }) }) diff --git a/services/pull/pull.go b/services/pull/pull.go index 891e358b6810e..b852dc2ed17c7 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -50,7 +50,7 @@ type NewPullRequestOptions struct { AssigneeIDs []int64 Reviewers []*user_model.User TeamReviewers []*organization.Team - ProjectID int64 + ProjectIDs []int64 } // NewPullRequest creates new pull request with labels for repository. @@ -110,8 +110,8 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error { assigneeCommentMap[assigneeID] = comment } - if opts.ProjectID > 0 && canAssignProject { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectID, 0); err != nil { + if len(opts.ProjectIDs) > 0 && canAssignProject { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, opts.ProjectIDs); err != nil { return err } } diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index d1459ff9425b7..961995127b873 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -1,4 +1,8 @@ -{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} +{{$projectIDs := $.ProjectIDs}} +{{$projectIDsQuery := SliceUtils.JoinInt64 $projectIDs}} +{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $projectIDsQuery "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} +{{$showAllProjects := not $projectIDs}} +{{$showNoProjectSelected := and (eq (len $projectIDs) 1) (eq (index $projectIDs 0) -1)}} {{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}} @@ -12,26 +16,28 @@ {{end}} -