Allow multiple projects per issue and pull requests (#36784)

Add ability to add and remove multiple projects per issue
and pull request.

Resolve #12974

---------

Signed-off-by: Icy Avocado <avocado@ovacoda.com>
Co-authored-by: Tyrone Yeh <siryeh@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: OpenCode (gpt-5.2-codex) <opencode@openai.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Icy Avocado
2026-04-30 08:38:05 -06:00
committed by GitHub
parent 52d6baf5a8
commit 81692ceafa
58 changed files with 1597 additions and 430 deletions

View File

@@ -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))

View File

@@ -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{}
}

View File

@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
project_model "code.gitea.io/gitea/models/project"
api "code.gitea.io/gitea/modules/structs"
)
// ToAPIProject converts a Project to API format
func ToAPIProject(p *project_model.Project) *api.Project {
apiProject := &api.Project{
ID: p.ID,
Title: p.Title,
Description: p.Description,
OwnerID: p.OwnerID,
RepoID: p.RepoID,
CreatorID: p.CreatorID,
IsClosed: p.IsClosed,
Created: p.CreatedUnix.AsTime(),
Updated: p.UpdatedUnix.AsTime(),
}
if p.IsClosed && p.ClosedDateUnix > 0 {
apiProject.Closed = p.ClosedDateUnix.AsTimePtr()
}
return apiProject
}
// ToAPIProjectList converts a list of Projects to API format
func ToAPIProjectList(projects []*project_model.Project) []*api.Project {
result := make([]*api.Project, len(projects))
for i := range projects {
result[i] = ToAPIProject(projects[i])
}
return result
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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
})
})

View File

@@ -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
}
}