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 <shivanshgarg587@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-10-24 06:24:46 +00:00
1 parent cf3d325e45
commit 35a6f3d4e0
5 files changed
+28 -11

No files matched your search

+12 -6
View File
@@ -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
}
+2 -1
View File
@@ -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)
+4 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+9
View File
@@ -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) {