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

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ type issueSidebarProjectCardData struct {
}
type issueSidebarProjectsData struct {
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
SelectedProjectIDs []int64
ProjectCards []*issueSidebarProjectCardData
OpenProjects []*project_model.Project
@@ -171,33 +171,49 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
}
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
if d.Issue == nil || d.Issue.Project == nil {
func (d *IssuePageMetaData) retrieveProjectCardsForExistingIssue(ctx *context.Context) {
if err := d.Issue.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
columns, err := d.Issue.Project.GetColumns(ctx)
// Load column mappings for all projects
projectColumnMap, err := d.Issue.ProjectColumnMap(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
ctx.ServerError("ProjectColumnMap", err)
return
}
columnID, err := d.Issue.ProjectColumnID(ctx)
if err != nil {
ctx.ServerError("ProjectColumnID", err)
return
}
var selectedColumn *project_model.Column
for _, col := range columns {
if col.ID == columnID {
selectedColumn = col
break
// Build project cards for each project
d.ProjectsData.ProjectCards = make([]*issueSidebarProjectCardData, 0, len(d.Issue.Projects))
for _, project := range d.Issue.Projects {
columns, err := project.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
}
d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{
{
Project: d.Issue.Project,
var selectedColumn *project_model.Column
columnID := projectColumnMap[project.ID]
for _, col := range columns {
if col.ID == columnID {
selectedColumn = col
break
}
}
if selectedColumn == nil {
selectedColumn, err = project.MustDefaultColumn(ctx)
if err != nil {
ctx.ServerError("MustDefaultColumn", err)
return
}
}
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{
Project: project,
Columns: columns,
SelectedColumn: selectedColumn,
},
})
}
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
for _, card := range d.ProjectsData.ProjectCards {
@@ -205,6 +221,29 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
}
}
func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
if d.Issue == nil {
return
}
d.retrieveProjectCardsForExistingIssue(ctx)
}
func (d *IssuePageMetaData) SetSelectedProjectIDs(ids []int64) {
allProjects := map[int64]*project_model.Project{}
for _, p := range d.ProjectsData.OpenProjects {
allProjects[p.ID] = p
}
for _, p := range d.ProjectsData.ClosedProjects {
allProjects[p.ID] = p
}
for _, id := range ids {
if project, ok := allProjects[id]; ok {
d.ProjectsData.ProjectCards = append(d.ProjectsData.ProjectCards, &issueSidebarProjectCardData{Project: project})
}
}
d.ProjectsData.SelectedProjectIDs = ids
}
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
}

View File

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

View File

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

View File

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