From d48061bb19484c98c37071845dfdde240956f8ce Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 09:02:35 +0800 Subject: [PATCH 01/23] Added multi-project feature --- models/issues/issue.go | 22 ++-- models/issues/issue_list.go | 9 +- models/issues/issue_list_test.go | 6 +- models/issues/issue_project.go | 113 ++++++++++-------- models/issues/issue_test.go | 6 +- modules/indexer/issues/internal/model.go | 2 +- .../indexer/issues/internal/tests/tests.go | 16 ++- modules/indexer/issues/util.go | 8 +- modules/util/util.go | 24 ++++ routers/api/v1/repo/issue.go | 2 +- routers/web/repo/issue_new.go | 46 +++---- routers/web/repo/issue_page_meta.go | 10 +- routers/web/repo/projects.go | 8 +- routers/web/repo/pull.go | 6 +- services/forms/repo_form.go | 2 +- services/issue/issue.go | 9 +- services/projects/issue_test.go | 4 +- .../repo/issue/sidebar/project_list.tmpl | 15 ++- templates/shared/issuelist.tmpl | 8 +- 19 files changed, 185 insertions(+), 131 deletions(-) diff --git a/models/issues/issue.go b/models/issues/issue.go index a86d50ca9da3c..4044677f7f0d4 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -74,17 +74,17 @@ 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:"-"` Priority int AssigneeID int64 `xorm:"-"` Assignee *user_model.User `xorm:"-"` diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 6c74b533b3c54..1faf9dacaf4a1 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -219,14 +219,19 @@ func (issues IssueList) LoadProjects(ctx context.Context) error { return err } for _, project := range projects { - projectMaps[project.IssueID] = project.Project + projectMaps[project.ID] = project.Project } left -= limit issueIDs = issueIDs[limit:] } for _, issue := range issues { - issue.Project = projectMaps[issue.ID] + projectIDs := issue.projectIDs(ctx) + for _, i := range projectIDs { + if projectMaps[i] != nil { + issue.Projects = append(issue.Projects, projectMaps[i]) + } + } } return nil } diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go index 5b4d2ca5ab9be..de97ff9ae7af1 100644 --- a/models/issues/issue_list_test.go +++ b/models/issues/issue_list_test.go @@ -66,10 +66,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.NotNil(t, issue.Projects[0]) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Nil(t, issue.Projects[0]) } } } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 01852447834c7..ced2f9c08706c 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -14,27 +14,21 @@ import ( // 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"). + if len(issue.Projects) == 0 { + 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).Find(&issue.Projects) } 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 +func (issue *Issue) projectIDs(ctx context.Context) []int64 { + var ids []int64 + if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id=?", issue.ID).Cols("project_id").Find(&ids); err != nil { + return nil } - return ip.ProjectID + + return ids } // ProjectColumnID return project column id if issue was assigned to one @@ -96,17 +90,52 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is // 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 { +func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64, newColumnID int64) error { return db.WithTx(ctx, func(ctx context.Context) error { - oldProjectID := issue.projectID(ctx) + oldProjectIDs := issue.projectIDs(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) + projectDB := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID) + newProjectIDs, oldProjectIDs := util.DiffSlice(oldProjectIDs, newProjectIDs) + + if len(oldProjectIDs) > 0 { + if _, err := projectDB.Where("issue_id=?", issue.ID).In("project_id", oldProjectIDs).Delete(&project_model.ProjectIssue{}); err != nil { + return err + } + for _, pID := range oldProjectIDs { + if _, err := CreateComment(ctx, &CreateCommentOptions{ + Type: CommentTypeProject, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldProjectID: pID, + ProjectID: 0, + }); err != nil { + return err + } + } + return nil + } + + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + if _, err := projectDB.Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). + In("project_id", newProjectIDs). + And("project_board_id=?", newColumnID). + Get(&res); err != nil { + return err + } + newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + + pi := make([]*project_model.ProjectIssue, 0, len(newProjectIDs)) + + for _, pID := range newProjectIDs { + newProject, err := project_model.GetProjectByID(ctx, pID) if err != nil { return err } @@ -119,48 +148,34 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo return err } newColumnID = newDefaultColumn.ID + if newColumnID == 0 { + panic("newColumnID must not be zero") // shouldn't happen + } } - } - if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { - return err - } + pi = append(pi, &project_model.ProjectIssue{ + IssueID: issue.ID, + ProjectID: pID, + ProjectColumnID: newColumnID, + Sorting: newSorting, + }) - if oldProjectID > 0 || newProjectID > 0 { if _, err := CreateComment(ctx, &CreateCommentOptions{ Type: CommentTypeProject, Doer: doer, Repo: issue.Repo, Issue: issue, - OldProjectID: oldProjectID, - ProjectID: newProjectID, + OldProjectID: 0, + ProjectID: pID, }); err != nil { return err } } - if newProjectID == 0 { - return nil - } - if newColumnID == 0 { - panic("newColumnID must not be zero") // shouldn't happen - } - res := struct { - MaxSorting int64 - IssueCount int64 - }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). - Where("project_id=?", newProjectID). - And("project_board_id=?", newColumnID). - Get(&res); err != nil { - return err + if len(pi) > 0 { + return db.Insert(ctx, pi) } - newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) - return db.Insert(ctx, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: newProjectID, - ProjectColumnID: newColumnID, - Sorting: newSorting, - }) + + return nil }) } diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 18571e3aaa1bc..8b20e6d69fa36 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -417,10 +417,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.NotNil(t, issue.Projects[0]) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Nil(t, issue.Projects[0]) } } } diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index 0d4f0f727d53c..e0ef52a437f2d 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -30,7 +30,7 @@ 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"` + ProjectIDs []int64 `json:"project_id"` ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible PosterID int64 `json:"poster_id"` AssigneeID int64 `json:"assignee_id"` diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index a42ec9a2bc25c..dc082eacd4d78 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -302,7 +302,7 @@ var cases = []*testIndexerCase{ }, }, { - Name: "ProjectID", + Name: "ProjectIDs", SearchOptions: &internal.SearchOptions{ Paginator: &db.ListOptions{ PageSize: 5, @@ -312,10 +312,10 @@ var cases = []*testIndexerCase{ 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.Equal(t, int64(1), data[v.ID].ProjectIDs[0]) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 1 + return v.ProjectIDs[0] == 1 }), result.Total) }, }, @@ -330,10 +330,10 @@ var cases = []*testIndexerCase{ 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, int64(0), data[v.ID].ProjectIDs[0]) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 0 + return v.ProjectIDs[0] == 0 }), result.Total) }, }, @@ -691,6 +691,10 @@ func generateDefaultIndexerData() []*internal.IndexerData { for i := range labelIDs { labelIDs[i] = int64(i) + 1 // LabelID should not be 0 } + projectIDs := make([]int64, id%5) + for i := range projectIDs { + projectIDs[i] = int64(i) + 1 // projectIDs should not be 0 + } mentionIDs := make([]int64, id%6) for i := range mentionIDs { mentionIDs[i] = int64(i) + 1 // MentionID should not be 0 @@ -720,7 +724,7 @@ func generateDefaultIndexerData() []*internal.IndexerData { LabelIDs: labelIDs, NoLabel: len(labelIDs) == 0, MilestoneID: issueIndex % 4, - ProjectID: issueIndex % 5, + ProjectIDs: projectIDs, ProjectColumnID: issueIndex % 6, PosterID: id%10 + 1, // PosterID should not be 0 AssigneeID: issueIndex % 10, diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go index 19d835a1d80aa..e8603c95424ec 100644 --- a/modules/indexer/issues/util.go +++ b/modules/indexer/issues/util.go @@ -87,9 +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 + projectIDs := make([]int64, 0, len(issue.Projects)) + for _, project := range issue.Projects { + projectIDs = append(projectIDs, project.ID) } projectColumnID, err := issue.ProjectColumnID(ctx) @@ -110,7 +110,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD LabelIDs: labels, NoLabel: len(labels) == 0, MilestoneID: issue.MilestoneID, - ProjectID: projectID, + ProjectIDs: projectIDs, ProjectColumnID: projectColumnID, PosterID: issue.PosterID, AssigneeID: issue.AssigneeID, diff --git a/modules/util/util.go b/modules/util/util.go index dd8e073888353..714c6bda7d7f9 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -253,3 +253,27 @@ func ReserveLineBreakForTextarea(input string) string { // Other than this, we should respect the original content, even leading or trailing spaces. return strings.ReplaceAll(input, "\r\n", "\n") } + +func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) { + oldSet := make(map[T]struct{}, len(oldSlice)) + newSet := make(map[T]struct{}, len(newSlice)) + + for _, v := range oldSlice { + oldSet[v] = struct{}{} + } + for _, v := range newSlice { + newSet[v] = struct{}{} + } + + for v := range newSet { + if _, found := oldSet[v]; !found { + added = append(added, v) + } + } + for v := range oldSet { + if _, found := newSet[v]; !found { + removed = append(removed, v) + } + } + return added, removed +} diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index e678db526203c..25219fa7023d1 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -721,7 +721,7 @@ 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 err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, nil); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.APIError(http.StatusBadRequest, err) } else if errors.Is(err, user_model.ErrBlockedUser) { diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index d8863961ff865..0ca4bbd1c22b1 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -120,8 +120,8 @@ func NewIssue(ctx *context.Context) { } pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") - pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project") - if pageMetaData.ProjectsData.SelectedProjectID > 0 { + pageMetaData.ProjectsData.SelectedProjectID = ctx.FormString("project") + if len(pageMetaData.ProjectsData.SelectedProjectID) > 0 { if len(ctx.Req.URL.Query().Get("project")) > 0 { ctx.Data["redirect_after_creation"] = "project" } @@ -240,8 +240,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 @@ -270,11 +271,14 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo 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 + inputProjectIDs, _ := base.StringsToInt64s(strings.Split(form.ProjectIDs, ",")) + var projectIDStrings []string + for _, inputProjectID := range inputProjectIDs { + if candidateProjects.Contains(inputProjectID) { + projectIDStrings = append(projectIDStrings, strconv.FormatInt(inputProjectID, 10)) + } } - pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID + pageMetaData.ProjectsData.SelectedProjectID = strings.Join(projectIDStrings, ",") // prepare assignees candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) @@ -319,7 +323,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo } } - ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID + ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectIDs = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, inputProjectIDs ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers return ret } @@ -344,9 +348,9 @@ func NewIssuePost(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 - if projectID > 0 { + if len(projectIDs) > 0 { if !ctx.Repo.CanRead(unit.TypeProjects) { // User must also be able to see the project. ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects") @@ -386,7 +390,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) { @@ -398,15 +402,17 @@ 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 err == nil { - if project.Type == project_model.TypeOrganization { - ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID)) - } else { - ctx.JSONRedirect(project_model.ProjectLinkForRepo(repo, project.ID)) + if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 { + for _, projectID := range projectIDs { + project, err := project_model.GetProjectByID(ctx, projectID) + if err == nil { + if project.Type == project_model.TypeOrganization { + ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID)) + } else { + ctx.JSONRedirect(project_model.ProjectLinkForRepo(repo, project.ID)) + } + return } - return } } ctx.JSONRedirect(issue.Link()) diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 93cc38bffa1cf..0eb14cf9578fd 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -34,7 +34,7 @@ type issueSidebarAssigneesData struct { } type issueSidebarProjectsData struct { - SelectedProjectID int64 + SelectedProjectID string OpenProjects []*project_model.Project ClosedProjects []*project_model.Project } @@ -160,8 +160,12 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { } func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { - if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectID = d.Issue.Project.ID + if d.Issue != nil && len(d.Issue.Projects) > 0 { + ids := make([]string, 0, len(d.Issue.Projects)) + for _, a := range d.Issue.Projects { + ids = append(ids, strconv.FormatInt(a.ID, 10)) + } + d.ProjectsData.SelectedProjectID = strings.Join(ids, ",") } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 0bf1f64d09475..2c46440641d86 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" @@ -444,12 +445,9 @@ func UpdateIssueProject(ctx *context.Context) { return } - projectID := ctx.FormInt64("id") + projectIDs, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ",")) 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, 0); err != nil { if errors.Is(err, util.ErrPermissionDenied) { continue } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 43ddc265cfafb..656255a649bb4 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1305,7 +1305,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 if setting.Attachment.Enabled { attachments = form.Files @@ -1411,8 +1411,8 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { - if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { + if ctx.Repo.CanWrite(unit.TypeProjects) { + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectIDs, 0); err != nil { if !errors.Is(err, util.ErrPermissionDenied) { ctx.ServerError("IssueAssignOrRemoveProject", err) return diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index a2827e516a5d8..9277472a52d5e 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -425,7 +425,7 @@ type CreateIssueForm struct { ReviewerIDs string `form:"reviewer_ids"` Ref string `form:"ref"` MilestoneID int64 - ProjectID int64 + ProjectIDs string `form:"project_ids"` Content string Files []string AllowMaintainerEdit bool diff --git a/services/issue/issue.go b/services/issue/issue.go index 455a1ec29781b..54dfa67cacacb 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,12 +41,7 @@ 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 { - return err - } - } - return nil + return issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectIDs, 0) }); err != nil { return err } diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go index e76d31e7574bc..b05bb992100f3 100644 --- a/services/projects/issue_test.go +++ b/services/projects/issue_test.go @@ -117,12 +117,12 @@ func Test_Projects(t *testing.T) { // issue 6 belongs to private repo 3 under org 3 issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6}) - err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue6, user2, project1.ID, column1.ID) + err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue6, user2, []int64{project1.ID}, column1.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(db.DefaultContext, issue16, user2, project1.ID, column1.ID) + err = issues_model.IssueAssignOrRemoveProject(db.DefaultContext, issue16, user2, []int64{project1.ID}, column1.ID) assert.NoError(t, err) projects, err := db.Find[project_model.Project](db.DefaultContext, project_model.SearchOptions{ diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index a212261a226f8..b260c7ec66484 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -1,11 +1,11 @@ {{$pageMeta := .}} {{$data := .ProjectsData}} -{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}} +{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Projects}}{{$issueProject = $pageMeta.Issue.Projects}}{{end}}
-
- + diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index 30670c3b0fc74..2c392877c1dd3 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -73,10 +73,10 @@ {{.Milestone.Name}} {{end}} - {{if .Project}} - - {{svg .Project.IconName 14}} - {{.Project.Title}} + {{range .Projects}} + + {{svg .IconName 14}} + {{.Title}} {{end}} {{if .Ref}}{{/* TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" */}} From 3a2ffcbc978da1106d288da421b1a87e4a956795 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 10:41:18 +0800 Subject: [PATCH 02/23] Fix board column move issue --- services/projects/issue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/projects/issue.go b/services/projects/issue.go index 590fe960d5329..80562480af93a 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -77,7 +77,7 @@ 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) + _, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=? AND project_id=?", column.ID, sorting, issueID, column.ProjectID) if err != nil { return err } From a82a39d3226454ce855c46c2805b110867cca695 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 12:25:12 +0800 Subject: [PATCH 03/23] Try fixing your unit tests --- models/issues/issue_project.go | 4 ++-- models/issues/issue_search.go | 18 ++++++++++++------ modules/indexer/issues/bleve/bleve.go | 9 +++++++-- modules/indexer/issues/db/options.go | 5 ++++- modules/indexer/issues/dboptions.go | 6 ++---- .../issues/elasticsearch/elasticsearch.go | 4 ++-- modules/indexer/issues/internal/model.go | 2 +- modules/indexer/issues/internal/tests/tests.go | 4 ++-- .../indexer/issues/meilisearch/meilisearch.go | 4 ++-- routers/web/repo/issue_list.go | 18 +++++++++--------- services/projects/issue.go | 10 +++++----- 11 files changed, 48 insertions(+), 36 deletions(-) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index ced2f9c08706c..71b589d0666c3 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -62,7 +62,7 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) { issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { o.ProjectColumnID = b.ID - o.ProjectID = b.ProjectID + o.ProjectIDs = []int64{b.ProjectID} o.SortType = "project-column-sorting" })) if err != nil { @@ -72,7 +72,7 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is if b.Default { issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { o.ProjectColumnID = db.NoConditionID - o.ProjectID = b.ProjectID + o.ProjectIDs = []int64{b.ProjectID} o.SortType = "project-column-sorting" })) if err != nil { diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index f9e1fbeb146de..0c17971e07e75 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -36,7 +36,7 @@ type IssuesOptions struct { //nolint ReviewedID int64 SubscriberID int64 MilestoneIDs []int64 - ProjectID int64 + ProjectIDs []int64 ProjectColumnID int64 IsClosed optional.Option[bool] IsPull optional.Option[bool] @@ -196,11 +196,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}))) + if len(opts.ProjectIDs) > 0 { // specific project + var includedProjectIDs []int64 + for _, projectID := range opts.ProjectIDs { + if projectID > 0 { + includedProjectIDs = append(includedProjectIDs, projectID) + } + } + if len(includedProjectIDs) > 0 { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + In("project_issue.project_id", includedProjectIDs) + } } // opts.ProjectID == 0 means all projects, // do not need to apply any condition diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index 39d96cab9853c..f7dd6ea03d5a4 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -241,9 +241,14 @@ 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 len(options.ProjectIDs) > 0 { + var projectQueries []query.Query + for _, projectID := range options.ProjectIDs { + projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_id")) + } + queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...)) } + if options.ProjectColumnID.Has() { queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) } diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index 380a25dc23dbc..e5359ad105934 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -65,7 +65,6 @@ 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), IsClosed: options.IsClosed, IsPull: options.IsPull, @@ -88,6 +87,10 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m opts.MilestoneIDs = options.MilestoneIDs } + if len(options.ProjectIDs) > 0 { + opts.ProjectIDs = options.ProjectIDs + } + if options.NoLabelOnly { opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID } else { diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index f17724664d0fa..ccc8c95ae7d5d 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -46,10 +46,8 @@ 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) > 0 { + searchOpt.ProjectIDs = opts.ProjectIDs } searchOpt.AssigneeID = opts.AssigneeID diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 9d627466ef427..1bcbd67c1d14c 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -204,8 +204,8 @@ 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 len(options.ProjectIDs) > 0 { + query.Must(elastic.NewTermsQuery("project_id", toAnySlice(options.ProjectIDs)...)) } if options.ProjectColumnID.Has() { query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index e0ef52a437f2d..8a251dd2abcc0 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -94,7 +94,7 @@ type SearchOptions struct { MilestoneIDs []int64 // milestones the issues have - ProjectID optional.Option[int64] // project the issues belong to + ProjectIDs []int64 // project the issues belong to ProjectColumnID optional.Option[int64] // project column the issues belong to PosterID string // poster 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 dc082eacd4d78..5e628d1fe41bd 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -307,7 +307,7 @@ var cases = []*testIndexerCase{ 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) @@ -325,7 +325,7 @@ var cases = []*testIndexerCase{ Paginator: &db.ListOptions{ PageSize: 5, }, - ProjectID: optional.Some(int64(0)), + ProjectIDs: []int64{0}, }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Len(t, result.Hits, 5) diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 759a98473f780..c1edcfbe38b67 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -180,8 +180,8 @@ 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 len(options.ProjectIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("project_id", options.ProjectIDs...)) } if options.ProjectColumnID.Has() { query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 35107bc585dfb..6c0b6e7374c7f 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -189,7 +189,7 @@ func SearchIssues(ctx *context.Context) { IsClosed: isClosed, IncludedAnyLabelIDs: includedAnyLabels, MilestoneIDs: includedMilestones, - ProjectID: projectID, + ProjectIDs: projectID, SortBy: issue_indexer.SortByCreatedDesc, } @@ -345,12 +345,12 @@ 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, + ProjectIDs: projectID, + SortBy: issue_indexer.SortByCreatedDesc, } if since != 0 { searchOpt.UpdatedAfterUnix = optional.Some(since) @@ -542,7 +542,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 RepoIDs: []int64{repo.ID}, LabelIDs: preparedLabelFilter.SelectedLabelIDs, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: []int64{projectID}, AssigneeID: assigneeID, MentionedID: mentionedID, PosterID: posterUserID, @@ -629,7 +629,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 ReviewRequestedID: reviewRequestedID, ReviewedID: reviewedID, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: []int64{projectID}, IsClosed: isShowClosed, IsPull: isPullOption, LabelIDs: preparedLabelFilter.SelectedLabelIDs, diff --git a/services/projects/issue.go b/services/projects/issue.go index 80562480af93a..10bb1c5c07542 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -89,7 +89,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum // 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) (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 { @@ -180,10 +180,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 From 18f2d469d9140060366a636077d127256f4bfe24 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 13:15:35 +0800 Subject: [PATCH 04/23] Fix lint issue --- modules/indexer/issues/indexer_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 3e38ac49b719c..e91e5f5d89284 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -416,7 +416,7 @@ func searchIssueInProject(t *testing.T) { }{ { SearchOptions{ - ProjectID: optional.Some(int64(1)), + ProjectIDs: optional.Some(int64(1)), }, []int64{5, 3, 2, 1}, }, From f839864ee59002ae0889efc2eb0e93a9c7a29ddd Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 13:24:24 +0800 Subject: [PATCH 05/23] Fix add issue to multiple project issue --- models/issues/issue_project.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 71b589d0666c3..5d636989a6079 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -154,10 +154,9 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo } pi = append(pi, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: pID, - ProjectColumnID: newColumnID, - Sorting: newSorting, + IssueID: issue.ID, + ProjectID: pID, + Sorting: newSorting, }) if _, err := CreateComment(ctx, &CreateCommentOptions{ From e75c510cb23dac43979062cf385f7a8927224085 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 13:27:14 +0800 Subject: [PATCH 06/23] Remove lessuse code --- models/issues/issue_project.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 5d636989a6079..38d52f1fd4402 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -142,16 +142,6 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo 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 newColumnID == 0 { - panic("newColumnID must not be zero") // shouldn't happen - } - } pi = append(pi, &project_model.ProjectIssue{ IssueID: issue.ID, From 1f43f8a566bc7767803456831fa16c9e10624b6a Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 14:55:42 +0800 Subject: [PATCH 07/23] Fix search empty issue --- routers/web/repo/issue_list.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 6c0b6e7374c7f..bb00b902707b4 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -542,7 +542,6 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 RepoIDs: []int64{repo.ID}, LabelIDs: preparedLabelFilter.SelectedLabelIDs, MilestoneIDs: mileIDs, - ProjectIDs: []int64{projectID}, AssigneeID: assigneeID, MentionedID: mentionedID, PosterID: posterUserID, @@ -551,6 +550,11 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 IsPull: isPullOption, IssueIDs: nil, } + + if projectID > 0 { + statsOpts.ProjectIDs = []int64{projectID} + } + if keyword != "" { keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts)) if err != nil { From 4f2532de425644a10a8effeb23088bcf31317139 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 15:24:40 +0800 Subject: [PATCH 08/23] Improject filter search method --- models/issues/issue_search.go | 14 ++++---------- modules/util/util.go | 11 +++++++++++ routers/web/repo/issue_list.go | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 0c17971e07e75..e05f8c26f6546 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" @@ -196,17 +197,10 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) { } func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { + opts.ProjectIDs = util.RemoveValue(opts.ProjectIDs, 0) if len(opts.ProjectIDs) > 0 { // specific project - var includedProjectIDs []int64 - for _, projectID := range opts.ProjectIDs { - if projectID > 0 { - includedProjectIDs = append(includedProjectIDs, projectID) - } - } - if len(includedProjectIDs) > 0 { - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - In("project_issue.project_id", includedProjectIDs) - } + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + In("project_issue.project_id", opts.ProjectIDs) } // opts.ProjectID == 0 means all projects, // do not need to apply any condition diff --git a/modules/util/util.go b/modules/util/util.go index 714c6bda7d7f9..f61c6aa420e76 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -277,3 +277,14 @@ func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) { } return added, removed } + +func RemoveValue[T comparable](a []T, target T) []T { + n := 0 + for _, v := range a { + if v != target { + a[n] = v + n++ + } + } + return a[:n] +} diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index bb00b902707b4..2de42be7eaf5d 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -551,7 +551,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 IssueIDs: nil, } - if projectID > 0 { + if projectID != 0 { statsOpts.ProjectIDs = []int64{projectID} } From 6a272eb0f43f5fad5d983c81e3bcae28a7ab6df1 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 15:31:23 +0800 Subject: [PATCH 09/23] Try fix unit test fail --- modules/indexer/issues/internal/tests/tests.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 5e628d1fe41bd..468a0fda762cd 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -312,10 +312,15 @@ var cases = []*testIndexerCase{ 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].ProjectIDs[0]) + if len(data[v.ID].ProjectIDs) > 0 { + assert.Equal(t, int64(1), data[v.ID].ProjectIDs[0]) + } } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectIDs[0] == 1 + if len(data[v.ID].ProjectIDs) > 0 { + return v.ProjectIDs[0] == 1 + } + return false }), result.Total) }, }, @@ -330,10 +335,15 @@ var cases = []*testIndexerCase{ 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].ProjectIDs[0]) + if len(data[v.ID].ProjectIDs) > 0 { + assert.Equal(t, int64(0), data[v.ID].ProjectIDs[0]) + } } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectIDs[0] == 0 + if len(data[v.ID].ProjectIDs) > 0 { + return v.ProjectIDs[0] == 1 + } + return false }), result.Total) }, }, From be48c8bada53f44f3f4bcc4a279cb29d28be96e7 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Wed, 7 May 2025 16:32:34 +0800 Subject: [PATCH 10/23] Try fix unit test issue --- modules/indexer/issues/internal/tests/tests.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 468a0fda762cd..97a7ca1fab978 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -701,10 +701,6 @@ func generateDefaultIndexerData() []*internal.IndexerData { for i := range labelIDs { labelIDs[i] = int64(i) + 1 // LabelID should not be 0 } - projectIDs := make([]int64, id%5) - for i := range projectIDs { - projectIDs[i] = int64(i) + 1 // projectIDs should not be 0 - } mentionIDs := make([]int64, id%6) for i := range mentionIDs { mentionIDs[i] = int64(i) + 1 // MentionID should not be 0 @@ -734,7 +730,7 @@ func generateDefaultIndexerData() []*internal.IndexerData { LabelIDs: labelIDs, NoLabel: len(labelIDs) == 0, MilestoneID: issueIndex % 4, - ProjectIDs: projectIDs, + ProjectIDs: []int64{issueIndex % 5}, ProjectColumnID: issueIndex % 6, PosterID: id%10 + 1, // PosterID should not be 0 AssigneeID: issueIndex % 10, From 0365eecd3669740588edae361c8299d2499adc0a Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 8 May 2025 10:57:42 +0800 Subject: [PATCH 11/23] Fix no project filter list issue --- models/issues/issue_search.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index e05f8c26f6546..fe5c4c4da2e9d 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -198,7 +198,9 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) { func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { opts.ProjectIDs = util.RemoveValue(opts.ProjectIDs, 0) - if len(opts.ProjectIDs) > 0 { // specific project + if len(opts.ProjectIDs) == 1 && opts.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(opts.ProjectIDs) > 0 { // specific project sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). In("project_issue.project_id", opts.ProjectIDs) } From ef8fa684b12ebfb2fac4dc6b8330c38bd102e987 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 8 May 2025 11:56:49 +0800 Subject: [PATCH 12/23] Fix unit test on TestIssueList_LoadAttributes --- models/issues/issue_list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go index de97ff9ae7af1..dc54c3e78ae58 100644 --- a/models/issues/issue_list_test.go +++ b/models/issues/issue_list_test.go @@ -69,7 +69,7 @@ func TestIssueList_LoadAttributes(t *testing.T) { assert.NotNil(t, issue.Projects[0]) assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Projects[0]) + assert.Nil(t, issue.Projects) } } } From f5717d045ccf6c114541aa599eb79a145771e58f Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 8 May 2025 13:19:58 +0800 Subject: [PATCH 13/23] Fix unit test on Test_Projects fail --- models/issues/issue_project.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 38d52f1fd4402..df4c8c702bb9b 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -144,9 +144,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo } pi = append(pi, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: pID, - Sorting: newSorting, + IssueID: issue.ID, + ProjectID: pID, + ProjectColumnID: newColumnID, + Sorting: newSorting, }) if _, err := CreateComment(ctx, &CreateCommentOptions{ From 41be9264580ed1bc5e332acd877d754694960713 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 8 May 2025 13:27:21 +0800 Subject: [PATCH 14/23] Fix unit test on TestIssueLoadAttributes --- models/issues/issue_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 8b20e6d69fa36..e2a5aa177366a 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -420,7 +420,7 @@ func TestIssueLoadAttributes(t *testing.T) { assert.NotNil(t, issue.Projects[0]) assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Projects[0]) + assert.Nil(t, issue.Projects) } } } From cce417c3a454f5ad714e75afafe16073905f0640 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 8 May 2025 15:20:24 +0800 Subject: [PATCH 15/23] Adjuect from Cols to Select --- models/issues/issue_project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index df4c8c702bb9b..885f86309d944 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -24,7 +24,7 @@ func (issue *Issue) LoadProject(ctx context.Context) (err error) { func (issue *Issue) projectIDs(ctx context.Context) []int64 { var ids []int64 - if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id=?", issue.ID).Cols("project_id").Find(&ids); err != nil { + if err := db.GetEngine(ctx).Table("project_issue").Where("issue_id=?", issue.ID).Select("project_id").Find(&ids); err != nil { return nil } From e912b7e3db631663d317c34c507f3020a47c15d9 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 9 May 2025 08:45:37 +0800 Subject: [PATCH 16/23] Improve adjust A to B project bug --- models/issues/issue_project.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 885f86309d944..501fa50fac2ee 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -117,6 +117,9 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo return err } } + } + + if len(newProjectIDs) == 0 { return nil } From 4c72c32f9bf6a126bb198af397dd89856a7981e5 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 9 May 2025 14:29:18 +0800 Subject: [PATCH 17/23] Improve string join code for projects id --- modules/util/util.go | 11 +++++++++++ routers/web/repo/issue_new.go | 11 +++++------ routers/web/repo/issue_page_meta.go | 9 ++++----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/modules/util/util.go b/modules/util/util.go index f61c6aa420e76..5efd2ae55a0e5 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -288,3 +288,14 @@ func RemoveValue[T comparable](a []T, target T) []T { } return a[:n] } + +func JoinSlice[T any](items []T, toString func(T) string) string { + var b strings.Builder + sep := "" + for _, item := range items { + b.WriteString(sep) + b.WriteString(toString(item)) + sep = "," + } + return b.String() +} diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 0ca4bbd1c22b1..094009f71762b 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -272,13 +272,12 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...) candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) inputProjectIDs, _ := base.StringsToInt64s(strings.Split(form.ProjectIDs, ",")) - var projectIDStrings []string - for _, inputProjectID := range inputProjectIDs { - if candidateProjects.Contains(inputProjectID) { - projectIDStrings = append(projectIDStrings, strconv.FormatInt(inputProjectID, 10)) + pageMetaData.ProjectsData.SelectedProjectID = util.JoinSlice(inputProjectIDs, func(v int64) string { + if candidateProjects.Contains(v) { + return strconv.FormatInt(v, 10) } - } - pageMetaData.ProjectsData.SelectedProjectID = strings.Join(projectIDStrings, ",") + return "" + }) // prepare assignees candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 0eb14cf9578fd..c0f23d4f39e01 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.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" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" @@ -161,11 +162,9 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { if d.Issue != nil && len(d.Issue.Projects) > 0 { - ids := make([]string, 0, len(d.Issue.Projects)) - for _, a := range d.Issue.Projects { - ids = append(ids, strconv.FormatInt(a.ID, 10)) - } - d.ProjectsData.SelectedProjectID = strings.Join(ids, ",") + d.ProjectsData.SelectedProjectID = util.JoinSlice(d.Issue.Projects, func(v *project_model.Project) string { + return strconv.FormatInt(v.ID, 10) + }) } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } From ec78073456b1dce9b41145e29cd74f6a5c62c0b8 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 9 May 2025 14:45:45 +0800 Subject: [PATCH 18/23] Fix on issue list clear project issue --- models/issues/issue_project.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 501fa50fac2ee..bab4ccbb18eeb 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -138,6 +138,9 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo pi := make([]*project_model.ProjectIssue, 0, len(newProjectIDs)) for _, pID := range newProjectIDs { + if pID == 0 { + continue + } newProject, err := project_model.GetProjectByID(ctx, pID) if err != nil { return err From 388533f97a170153a7473b01d1439ecfb935ee2b Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 9 May 2025 15:20:55 +0800 Subject: [PATCH 19/23] Fix list project item has icon issue --- templates/repo/issue/sidebar/project_list.tmpl | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index b260c7ec66484..8dcc6757cbc42 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -46,7 +46,6 @@ {{ctx.Locale.Tr "repo.issues.new.no_projects"}} {{range $issueProject}} - {{svg "octicon-check"}} {{svg .IconName 18}} {{.Title}} {{end}} From d132f8432319a72e2dfcd281704c701df4bb4334 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 9 May 2025 15:59:24 +0800 Subject: [PATCH 20/23] Change db.Exec to Update method --- services/projects/issue.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/services/projects/issue.go b/services/projects/issue.go index 10bb1c5c07542..b734748e1b5b8 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -77,7 +77,12 @@ 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=? AND project_id=?", column.ID, sorting, issueID, column.ProjectID) + _, 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 } From 967a233cbe44ed13c46e5e384482b1638b489f93 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Sun, 11 May 2025 15:10:44 +0800 Subject: [PATCH 21/23] Rename LoadProject to LoadProjects --- models/issues/issue.go | 2 +- models/issues/issue_project.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/models/issues/issue.go b/models/issues/issue.go index 4044677f7f0d4..4bd1c2b66597d 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -327,7 +327,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 } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index bab4ccbb18eeb..f89dd46280ff8 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -13,7 +13,7 @@ import ( ) // LoadProject load the project the issue was assigned to -func (issue *Issue) LoadProject(ctx context.Context) (err error) { +func (issue *Issue) LoadProjects(ctx context.Context) (err error) { if len(issue.Projects) == 0 { err = db.GetEngine(ctx).Table("project"). Join("INNER", "project_issue", "project.id=project_issue.project_id"). From 7e6750e998f83a788243b02e56ab109b9fd193c4 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Mon, 12 May 2025 08:22:09 +0800 Subject: [PATCH 22/23] Rename variable projectIDs to ids --- routers/web/repo/projects.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 2c46440641d86..0b284d0bbe280 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -445,9 +445,9 @@ func UpdateIssueProject(ctx *context.Context) { return } - projectIDs, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ",")) + ids, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ",")) for _, issue := range issues { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, ids, 0); err != nil { if errors.Is(err, util.ErrPermissionDenied) { continue } From 5c1bc2defc1a6d5af927fe1b16accd153cc2ce69 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Mon, 12 May 2025 10:29:14 +0800 Subject: [PATCH 23/23] Revert "Rename variable projectIDs to ids" This reverts commit 7e6750e998f83a788243b02e56ab109b9fd193c4. --- routers/web/repo/projects.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 0b284d0bbe280..2c46440641d86 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -445,9 +445,9 @@ func UpdateIssueProject(ctx *context.Context) { return } - ids, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ",")) + projectIDs, _ := base.StringsToInt64s(strings.Split(ctx.FormString("id"), ",")) for _, issue := range issues { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, ids, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectIDs, 0); err != nil { if errors.Is(err, util.ErrPermissionDenied) { continue }