From bcb4559c10914262052f129b627e4dd587a4946a Mon Sep 17 00:00:00 2001 From: Shivansh Garg Date: Sat, 24 Oct 2026 04:30:09 +0530 Subject: [PATCH] enhance(project): filter project milestones by linked issues (#39958) The project milestone filter listed every milestone of the repo, or of all accessible org repos for org projects, including ones with no issue in the project. That made the dropdown noisy and unhelpful. Both repo and org projects now list only milestones that have at least one issue linked to the project, via a new `ProjectID` option on `FindMilestoneOptions`. Fixes: https://github.com/go-gitea/gitea/issues/39150 Co-authored-by: silverwind --- models/issues/milestone_list.go | 18 ++++++++++++------ routers/web/org/projects.go | 3 ++- routers/web/repo/issue_list.go | 7 ++++--- routers/web/repo/projects.go | 2 +- tests/integration/project_test.go | 9 +++++++++ 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/models/issues/milestone_list.go b/models/issues/milestone_list.go index 8e5d48396bb..7892dd50b9d 100644 --- a/models/issues/milestone_list.go +++ b/models/issues/milestone_list.go @@ -39,12 +39,13 @@ func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList) // FindMilestoneOptions contain options to get milestones type FindMilestoneOptions struct { db.ListOptions - RepoID int64 - IsClosed optional.Option[bool] - Name string - SortType string - RepoCond builder.Cond - RepoIDs []int64 + RepoID int64 + IsClosed optional.Option[bool] + Name string + SortType string + RepoCond builder.Cond + RepoIDs []int64 + ProjectID int64 } func (opts FindMilestoneOptions) ToConds() builder.Cond { @@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond { if len(opts.Name) != 0 { cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name)) } + if opts.ProjectID != 0 { + cond = cond.And(builder.In("id", builder.Select("issue.milestone_id").From("issue"). + InnerJoin("project_issue", "project_issue.issue_id = issue.id"). + Where(builder.Eq{"project_issue.project_id": opts.ProjectID}))) + } return cond } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 183ac9075ce..1c681358198 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -440,7 +440,8 @@ func ViewProject(ctx *context.Context) { accessCond, ) milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ - RepoCond: repoCond, + RepoCond: repoCond, + ProjectID: project.ID, }) if err != nil { ctx.ServerError("GetOrgMilestones", err) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 0f12c478c6f..3bda048a1d6 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -380,10 +380,11 @@ func prepareIssueFilterExclusiveOrderScopes(ctx *context.Context, allLabels []*i ctx.Data["ExclusiveLabelScopes"] = scopes } -func renderMilestones(ctx *context.Context) { +func renderMilestones(ctx *context.Context, projectID int64) { // Get milestones milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ - RepoID: ctx.Repo.Repository.ID, + RepoID: ctx.Repo.Repository.ID, + ProjectID: projectID, }) if err != nil { ctx.ServerError("GetAllRepoMilestones", err) @@ -679,7 +680,7 @@ func Issues(ctx *context.Context) { return } - renderMilestones(ctx) + renderMilestones(ctx, 0) if ctx.Written() { return } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 8a8a4fc1c65..9c646082a59 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -415,7 +415,7 @@ func ViewProject(ctx *context.Context) { ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["AssigneeID"] = assigneeID - renderMilestones(ctx) + renderMilestones(ctx, project.ID) if ctx.Written() { return } diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index df9b50acd29..1bc56a15dc3 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -240,6 +240,12 @@ func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} { return ids } +func getProjectMilestoneNames(htmlDoc *HTMLDoc) []string { + return htmlDoc.Find(".list-header-filters a:has(.octicon-milestone)").Map(func(_ int, s *goquery.Selection) string { + return strings.TrimSpace(s.Text()) + }) +} + func TestRepoProjectFilterByMilestone(t *testing.T) { // Project 1 is on repo 1 (user2/repo1) and has issues: // issue 1 (milestone_id=0), issue 2 (milestone_id=1), issue 3 (milestone_id=3), issue 5 (milestone_id=0) @@ -257,6 +263,7 @@ func TestRepoProjectFilterByMilestone(t *testing.T) { assert.Contains(t, issueIDs, int64(2)) assert.Contains(t, issueIDs, int64(3)) assert.Contains(t, issueIDs, int64(5)) + assert.Equal(t, []string{"milestone1", "milestone3"}, getProjectMilestoneNames(htmlDoc)) }) t.Run("FilterByMilestone", func(t *testing.T) { @@ -312,6 +319,7 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { Name: "org-test-milestone", } require.NoError(t, issues_model.NewMilestone(t.Context(), milestone)) + require.NoError(t, issues_model.NewMilestone(t.Context(), &issues_model.Milestone{RepoID: repo.ID, Name: "unlinked-milestone"})) issue16.MilestoneID = milestone.ID require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue16, "milestone_id")) @@ -341,6 +349,7 @@ func TestOrgProjectFilterByMilestone(t *testing.T) { issueIDs := getProjectIssueIDs(t, htmlDoc) assert.Contains(t, issueIDs, issue16.ID) assert.Contains(t, issueIDs, issue17.ID) + assert.Equal(t, []string{milestone.Name}, getProjectMilestoneNames(htmlDoc)) }) t.Run("FilterByMilestone", func(t *testing.T) {