From 35a6f3d4e09713f3a6ea3fafc715c3773086afec Mon Sep 17 00:00:00 2001 From: Giteabot Date: Fri, 23 Oct 2026 23:24:46 -0700 Subject: [PATCH] enhance(project): filter project milestones by linked issues (#39958) (#39984) Backport #39958 by @SHIVANSHGARG07 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: Shivansh Garg 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 3b4bdc6e9f6..6cd5a63a1b3 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 4cf346aef84..0554d18bab2 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -438,7 +438,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 ab1ec7bbd64..48990320e87 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -412,7 +412,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 0ace73ea4ac..6ef3cb6157d 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -228,6 +228,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) @@ -245,6 +251,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) { @@ -300,6 +307,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")) @@ -327,6 +335,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) {