Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions routers/web/org/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -332,12 +332,22 @@ 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,
Doer: ctx.Doer,
}
Comment thread
josetduarte marked this conversation as resolved.
Comment thread
josetduarte marked this conversation as resolved.

Comment thread
josetduarte marked this conversation as resolved.
Outdated
issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts)
Expand All @@ -350,10 +360,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
}
}
Expand Down Expand Up @@ -411,6 +421,54 @@ 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.Milestone
if project.RepoID > 0 {
// Repo-specific project
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoID: project.RepoID,
})
Comment thread
josetduarte marked this conversation as resolved.
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
repoIDs, _, err := repo_model.SearchRepositoryIDs(ctx, repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Private: true,
Comment thread
josetduarte marked this conversation as resolved.
Outdated
OwnerID: project.OwnerID,
})
if err != nil {
ctx.ServerError("SearchRepositoryIDs", err)
return
}

if len(repoIDs) > 0 {
milestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoIDs: repoIDs,
})
if err != nil {
ctx.ServerError("GetOrgMilestones", err)
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)
}
}
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 {
Expand Down
39 changes: 36 additions & 3 deletions routers/web/repo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,25 @@ func ViewProject(ctx *context.Context) {
}

preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
Comment thread
josetduarte marked this conversation as resolved.
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,
})
Comment thread
josetduarte marked this conversation as resolved.
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
Expand Down Expand Up @@ -408,6 +420,27 @@ func ViewProject(ctx *context.Context) {
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["AssigneeID"] = assigneeID

// Get milestones
milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoID: ctx.Repo.Repository.ID,
})
if err != nil {
Comment thread
josetduarte marked this conversation as resolved.
Outdated
ctx.ServerError("GetMilestones", err)
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)
}
}
ctx.Data["OpenMilestones"] = openMilestones
ctx.Data["ClosedMilestones"] = closedMilestones
ctx.Data["MilestoneID"] = milestoneID

rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)
project.RenderedContent, err = markdown.RenderString(rctx, project.Description)
if err != nil {
Expand Down
38 changes: 37 additions & 1 deletion templates/projects/view.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<h2>{{.Project.Title}}</h2>
<div class="tw-flex-1"></div>
<div class="ui secondary menu tw-m-0">
{{$queryLink := QueryBuild "?" "labels" .SelectLabels "assignee" $.AssigneeID "archived_labels" (Iif $.ShowArchivedLabels "true")}}
{{$queryLink := QueryBuild "?" "labels" .SelectLabels "assignee" $.AssigneeID "milestone" $.MilestoneID "archived_labels" (Iif $.ShowArchivedLabels "true")}}
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
{{template "repo/issue/filter_item_user_assign" dict
"QueryParamKey" "assignee"
Expand All @@ -16,6 +16,42 @@
"TextFilterMatchNone" (ctx.Locale.Tr "repo.issues.filter_assignee_no_assignee")
"TextFilterMatchAny" (ctx.Locale.Tr "repo.issues.filter_assignee_any_assignee")
}}
<!-- Milestone -->
<div class="item ui dropdown jump {{if not (or .OpenMilestones .ClosedMilestones)}}disabled{{end}}">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_milestone"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestone"}}">
</div>
<div class="divider"></div>
<a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a>
<a class="{{if eq $.MilestoneID -1}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" -1}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_none"}}</a>
{{if .OpenMilestones}}
<div class="divider"></div>
<div class="header">{{ctx.Locale.Tr "repo.issues.filter_milestone_open"}}</div>
{{range .OpenMilestones}}
<a class="{{if eq $.MilestoneID .ID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" .ID}}">
{{svg "octicon-milestone" 16 "mr-2"}}
{{.Name}}
</a>
{{end}}
{{end}}
{{if .ClosedMilestones}}
<div class="divider"></div>
<div class="header">{{ctx.Locale.Tr "repo.issues.filter_milestone_closed"}}</div>
{{range .ClosedMilestones}}
<a class="{{if eq $.MilestoneID .ID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" .ID}}">
{{svg "octicon-milestone" 16 "mr-2"}}
{{.Name}}
</a>
{{end}}
{{end}}
</div>
Comment thread
josetduarte marked this conversation as resolved.
Outdated
</div>
</div>
{{if $canWriteProject}}
<div class="ui compact mini menu">
Expand Down
145 changes: 145 additions & 0 deletions tests/integration/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@ package integration
import (
"fmt"
"net/http"
"strconv"
"testing"

issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"

"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPrivateRepoProject(t *testing.T) {
Expand Down Expand Up @@ -83,3 +88,143 @@ func TestMoveRepoProjectColumns(t *testing.T) {

assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}

// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
t.Helper()
ids := make(map[int64]struct{})
htmlDoc.doc.Find(".issue-card[data-issue]").Each(func(_ int, s *goquery.Selection) {
Comment thread
josetduarte marked this conversation as resolved.
Outdated
idStr, exists := s.Attr("data-issue")
assert.True(t, exists)
id, err := strconv.ParseInt(idStr, 10, 64)
assert.NoError(t, err)
Comment thread
josetduarte marked this conversation as resolved.
Outdated
ids[id] = struct{}{}
})
return ids
}

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)
defer tests.PrepareTestEnv(t)()

sess := loginUser(t, "user2")

t.Run("NoFilter", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/projects/1")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
// All issues should be visible
assert.Contains(t, issueIDs, int64(1))
assert.Contains(t, issueIDs, int64(2))
assert.Contains(t, issueIDs, int64(3))
assert.Contains(t, issueIDs, int64(5))
})

t.Run("FilterByMilestone", func(t *testing.T) {
// milestone_id=1 is "milestone1" (open), only issue 2 has it
req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=1")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, int64(2))
assert.NotContains(t, issueIDs, int64(1))
assert.NotContains(t, issueIDs, int64(3))
assert.NotContains(t, issueIDs, int64(5))
})

t.Run("FilterByNoMilestone", func(t *testing.T) {
// milestone=-1 means "no milestone", issues 1 and 5 have no milestone
req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=-1")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, int64(1))
assert.Contains(t, issueIDs, int64(5))
assert.NotContains(t, issueIDs, int64(2))
assert.NotContains(t, issueIDs, int64(3))
})

t.Run("FilterByClosedMilestone", func(t *testing.T) {
// milestone_id=3 is "milestone3" (closed), only issue 3 has it
req := NewRequest(t, "GET", "/user2/repo1/projects/1?milestone=3")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, int64(3))
assert.NotContains(t, issueIDs, int64(1))
assert.NotContains(t, issueIDs, int64(2))
assert.NotContains(t, issueIDs, int64(5))
})
}

func TestOrgProjectFilterByMilestone(t *testing.T) {
defer tests.PrepareTestEnv(t)()

// org3 owns repo3 which has issues 6 and 12
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
issue6 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6})
issue12 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})

// Create a milestone on repo3 and assign it to issue6
milestone := &issues_model.Milestone{
RepoID: repo.ID,
Name: "org-test-milestone",
}
require.NoError(t, issues_model.NewMilestone(t.Context(), milestone))

issue6.MilestoneID = milestone.ID
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue6, "milestone_id"))

// Create an org-level project
project := project_model.Project{
Title: "org milestone filter test",
OwnerID: org.ID,
Type: project_model.TypeOrganization,
TemplateType: project_model.TemplateTypeNone,
}
require.NoError(t, project_model.NewProject(t.Context(), &project))

// Get the default column
columns, err := project.GetColumns(t.Context())
require.NoError(t, err)
require.NotEmpty(t, columns)
defaultColumnID := columns[0].ID

// Add issues to the project
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue6, user2, project.ID, defaultColumnID))
require.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue12, user2, project.ID, defaultColumnID))

sess := loginUser(t, "user2")
projectURL := fmt.Sprintf("/org3/-/projects/%d", project.ID)

t.Run("NoFilter", func(t *testing.T) {
req := NewRequest(t, "GET", projectURL)
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, issue6.ID)
assert.Contains(t, issueIDs, issue12.ID)
})

t.Run("FilterByMilestone", func(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("%s?milestone=%d", projectURL, milestone.ID))
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, issue6.ID)
assert.NotContains(t, issueIDs, issue12.ID)
})

t.Run("FilterByNoMilestone", func(t *testing.T) {
req := NewRequest(t, "GET", projectURL+"?milestone=-1")
resp := sess.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
issueIDs := getProjectIssueIDs(t, htmlDoc)
assert.Contains(t, issueIDs, issue12.ID)
assert.NotContains(t, issueIDs, issue6.ID)
})
}
Loading