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
+31 -21
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
}