From f76f5207a7d66139551a3cdee26bc0503c38ae0a Mon Sep 17 00:00:00 2001 From: josetduarte <6619440+josetduarte@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:09:32 +0000 Subject: [PATCH] feature to be able to filter project boards by milestones (#36321) This pull request adds milestone filtering support to both repository and organization project boards. Users can now filter project issues by milestone, similar to how they filter by label or assignee. The implementation includes backend changes to fetch and filter milestones, as well as frontend updates to display a milestone filter dropdown in the project board UI. **Milestone filtering support:** * Added support for filtering project board issues by milestone in both repository and organization contexts, including handling for "no milestone" and "all milestones" options. (`routers/web/repo/projects.go` [[1]](diffhunk://#diff-5cba331a1ddf1eea017178cfefaaff9ad72a4b05797fb84bf508b0939aae2972R316-R330) [[2]](diffhunk://#diff-5cba331a1ddf1eea017178cfefaaff9ad72a4b05797fb84bf508b0939aae2972R421-R441); `routers/web/org/projects.go` [[3]](diffhunk://#diff-f4279417070a8e33829c338abeb42877500377f490abb1495ae6357d50b6a765R344-R357) [[4]](diffhunk://#diff-f4279417070a8e33829c338abeb42877500377f490abb1495ae6357d50b6a765R433-R485) * Updated the project board template to include a milestone filter dropdown, displaying open and closed milestones and integrating with the query string for filtering. (`templates/projects/view.tmpl` [[1]](diffhunk://#diff-e2c7e14d247ce381c352263a8fa639b8341690ff85f6dbebfa166ee3306542feL8-R8) [[2]](diffhunk://#diff-e2c7e14d247ce381c352263a8fa639b8341690ff85f6dbebfa166ee3306542feR19-R58) Solves Issue #35224 --------- Signed-off-by: josetduarte <6619440+josetduarte@users.noreply.github.com> Co-authored-by: joseduarte Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- models/issues/milestone_list.go | 12 ++ routers/web/org/projects.go | 66 ++++++- routers/web/repo/issue_list.go | 9 +- routers/web/repo/projects.go | 24 ++- templates/projects/view.tmpl | 8 +- .../repo/issue/filter_item_milestone.tmpl | 42 +++++ templates/repo/issue/filter_list.tmpl | 42 +---- tests/integration/project_test.go | 165 ++++++++++++++++++ 8 files changed, 313 insertions(+), 55 deletions(-) create mode 100644 templates/repo/issue/filter_item_milestone.tmpl diff --git a/models/issues/milestone_list.go b/models/issues/milestone_list.go index 955ab2356df..021b8beb9e0 100644 --- a/models/issues/milestone_list.go +++ b/models/issues/milestone_list.go @@ -24,6 +24,18 @@ func (milestones MilestoneList) getMilestoneIDs() []int64 { return ids } +// SplitByOpenClosed splits the milestone list into open and closed milestones +func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList) { + for _, m := range milestones { + if m.IsClosed { + closed = append(closed, m) + } else { + open = append(open, m) + } + } + return open, closed +} + // FindMilestoneOptions contain options to get milestones type FindMilestoneOptions struct { db.ListOptions diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index f4a54db006a..e01e615de6f 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -13,7 +13,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" org_model "code.gitea.io/gitea/models/organization" project_model "code.gitea.io/gitea/models/project" - attachment_model "code.gitea.io/gitea/models/repo" + 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/optional" @@ -25,6 +25,8 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" project_service "code.gitea.io/gitea/services/projects" + + "xorm.io/builder" ) const ( @@ -332,12 +334,26 @@ func ViewProject(ctx *context.Context) { return } assigneeID := ctx.FormString("assignee") + milestoneID := ctx.FormInt64("milestone") + + // Prepare milestone IDs for filtering + var milestoneIDs []int64 + if milestoneID > 0 { + milestoneIDs = []int64{milestoneID} + } else if milestoneID == db.NoConditionID { + milestoneIDs = []int64{db.NoConditionID} + } opts := issues_model.IssuesOptions{ - LabelIDs: preparedLabelFilter.SelectedLabelIDs, - AssigneeID: assigneeID, - Owner: project.Owner, - Doer: ctx.Doer, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, + MilestoneIDs: milestoneIDs, + Owner: project.Owner, + } + if ctx.Doer != nil { + opts.Doer = ctx.Doer + } else { + opts.AllPublic = true } issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts) @@ -350,10 +366,10 @@ func ViewProject(ctx *context.Context) { } if project.CardType != project_model.CardTypeTextOnly { - issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + issuesAttachmentMap := make(map[int64][]*repo_model.Attachment) for _, issuesList := range issuesMap { for _, issue := range issuesList { - if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + if issueAttachment, err := repo_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { issuesAttachmentMap[issue.ID] = issueAttachment } } @@ -411,6 +427,42 @@ func ViewProject(ctx *context.Context) { ctx.Data["Labels"] = labels ctx.Data["NumLabels"] = len(labels) + // Get milestones for filtering + // For organization projects, we need to get milestones from all repos the user has access to + var milestones issues_model.MilestoneList + if project.RepoID > 0 { + // Repo-specific project + milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: project.RepoID, + }) + if err != nil { + ctx.ServerError("GetRepoMilestones", err) + return + } + } else { + // Organization-wide project - get milestones from all organization repos + // but only from repositories the current user can access. + // Use RepoCond with a subquery to avoid materializing all repo IDs in memory + // which can hit SQL parameter limits for orgs with many repos. + accessCond := repo_model.AccessibleRepositoryCondition(ctx.Doer, unit.TypeIssues) + repoCond := builder.And( + builder.Eq{"owner_id": project.OwnerID}, + accessCond, + ) + milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoCond: repoCond, + }) + if err != nil { + ctx.ServerError("GetOrgMilestones", err) + return + } + } + + openMilestones, closedMilestones := milestones.SplitByOpenClosed() + ctx.Data["OpenMilestones"] = openMilestones + ctx.Data["ClosedMilestones"] = closedMilestones + ctx.Data["MilestoneID"] = milestoneID + // Get assignees. assigneeUsers, err := org_model.GetOrgAssignees(ctx, project.OwnerID) if err != nil { diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index da0ba6c407d..ff4ff266854 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -462,14 +462,7 @@ func renderMilestones(ctx *context.Context) { return } - openMilestones, closedMilestones := issues_model.MilestoneList{}, issues_model.MilestoneList{} - for _, milestone := range milestones { - if milestone.IsClosed { - closedMilestones = append(closedMilestones, milestone) - } else { - openMilestones = append(openMilestones, milestone) - } - } + openMilestones, closedMilestones := issues_model.MilestoneList(milestones).SplitByOpenClosed() ctx.Data["OpenMilestones"] = openMilestones ctx.Data["ClosedMilestones"] = closedMilestones } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a57976b4ca8..e7a9e6ba124 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -311,13 +311,25 @@ func ViewProject(ctx *context.Context) { } preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) + if ctx.Written() { + return + } assigneeID := ctx.FormString("assignee") + milestoneID := ctx.FormInt64("milestone") + + var milestoneIDs []int64 + if milestoneID > 0 { + milestoneIDs = []int64{milestoneID} + } else if milestoneID == db.NoConditionID { + milestoneIDs = []int64{db.NoConditionID} + } issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{ - RepoIDs: []int64{ctx.Repo.Repository.ID}, - LabelIDs: preparedLabelFilter.SelectedLabelIDs, - AssigneeID: assigneeID, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, + MilestoneIDs: milestoneIDs, }) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) @@ -408,6 +420,12 @@ func ViewProject(ctx *context.Context) { ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["AssigneeID"] = assigneeID + renderMilestones(ctx) + if ctx.Written() { + return + } + ctx.Data["MilestoneID"] = milestoneID + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) project.RenderedContent, err = markdown.RenderString(rctx, project.Description) if err != nil { diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 5801396e3c5..09edcb11855 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -5,7 +5,7 @@

{{.Project.Title}}

{{if $canWriteProject}}