Skip to content

Commit 4ab6fc6

Browse files
authored
Add option to filter board cards by labels and assignees (#31999)
Works in both organization and repository project boards Fixes #21846 Replaces #21963 Replaces #27117 ![image](https://github.com/user-attachments/assets/1837ace8-3de2-444f-a153-e166bd0da2c0) **Note** that implementation was made intentionally to work same as in issue list so that URL can be bookmarked for quick access with predefined filters in URL
1 parent 20d7707 commit 4ab6fc6

File tree

14 files changed

+325
-33
lines changed

14 files changed

+325
-33
lines changed

models/issues/issue_project.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ func (issue *Issue) ProjectColumnID(ctx context.Context) int64 {
4848
}
4949

5050
// LoadIssuesFromColumn load issues assigned to this column
51-
func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueList, error) {
52-
issueList, err := Issues(ctx, &IssuesOptions{
53-
ProjectColumnID: b.ID,
54-
ProjectID: b.ProjectID,
55-
SortType: "project-column-sorting",
56-
})
51+
func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) {
52+
issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
53+
o.ProjectColumnID = b.ID
54+
o.ProjectID = b.ProjectID
55+
o.SortType = "project-column-sorting"
56+
}))
5757
if err != nil {
5858
return nil, err
5959
}
@@ -78,10 +78,10 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column) (IssueLi
7878
}
7979

8080
// LoadIssuesFromColumnList load issues assigned to the columns
81-
func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList) (map[int64]IssueList, error) {
81+
func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, opts *IssuesOptions) (map[int64]IssueList, error) {
8282
issuesMap := make(map[int64]IssueList, len(bs))
8383
for i := range bs {
84-
il, err := LoadIssuesFromColumn(ctx, bs[i])
84+
il, err := LoadIssuesFromColumn(ctx, bs[i], opts)
8585
if err != nil {
8686
return nil, err
8787
}

models/issues/issue_search.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ type IssuesOptions struct { //nolint
5454
User *user_model.User // issues permission scope
5555
}
5656

57+
// Copy returns a copy of the options.
58+
// Be careful, it's not a deep copy, so `IssuesOptions.RepoIDs = {...}` is OK while `IssuesOptions.RepoIDs[0] = ...` is not.
59+
func (o *IssuesOptions) Copy(edit ...func(options *IssuesOptions)) *IssuesOptions {
60+
if o == nil {
61+
return nil
62+
}
63+
v := *o
64+
for _, e := range edit {
65+
e(&v)
66+
}
67+
return &v
68+
}
69+
5770
// applySorts sort an issues-related session based on the provided
5871
// sortType string
5972
func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {

models/organization/org_user.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99

1010
"code.gitea.io/gitea/models/db"
1111
"code.gitea.io/gitea/models/perm"
12+
"code.gitea.io/gitea/models/unit"
1213
user_model "code.gitea.io/gitea/models/user"
14+
"code.gitea.io/gitea/modules/container"
1315
"code.gitea.io/gitea/modules/log"
1416

1517
"xorm.io/builder"
@@ -112,6 +114,49 @@ func IsUserOrgOwner(ctx context.Context, users user_model.UserList, orgID int64)
112114
return results
113115
}
114116

117+
// GetOrgAssignees returns all users that have write access and can be assigned to issues
118+
// of the any repository in the organization.
119+
func GetOrgAssignees(ctx context.Context, orgID int64) (_ []*user_model.User, err error) {
120+
e := db.GetEngine(ctx)
121+
userIDs := make([]int64, 0, 10)
122+
if err = e.Table("access").
123+
Join("INNER", "repository", "`repository`.id = `access`.repo_id").
124+
Where("`repository`.owner_id = ? AND `access`.mode >= ?", orgID, perm.AccessModeWrite).
125+
Select("user_id").
126+
Find(&userIDs); err != nil {
127+
return nil, err
128+
}
129+
130+
additionalUserIDs := make([]int64, 0, 10)
131+
if err = e.Table("team_user").
132+
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
133+
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
134+
Join("INNER", "repository", "`repository`.id = `team_repo`.repo_id").
135+
Where("`repository`.owner_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))",
136+
orgID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests).
137+
Distinct("`team_user`.uid").
138+
Select("`team_user`.uid").
139+
Find(&additionalUserIDs); err != nil {
140+
return nil, err
141+
}
142+
143+
uniqueUserIDs := make(container.Set[int64])
144+
uniqueUserIDs.AddMultiple(userIDs...)
145+
uniqueUserIDs.AddMultiple(additionalUserIDs...)
146+
147+
users := make([]*user_model.User, 0, len(uniqueUserIDs))
148+
if len(userIDs) > 0 {
149+
if err = e.In("id", uniqueUserIDs.Values()).
150+
Where(builder.Eq{"`user`.is_active": true}).
151+
OrderBy(user_model.GetOrderByName()).
152+
Find(&users); err != nil {
153+
return nil, err
154+
}
155+
}
156+
157+
return users, nil
158+
}
159+
115160
func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) {
116161
if len(users) == 0 {
117162
return nil, nil

routers/web/org/projects.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"code.gitea.io/gitea/models/db"
1313
issues_model "code.gitea.io/gitea/models/issues"
14+
org_model "code.gitea.io/gitea/models/organization"
1415
project_model "code.gitea.io/gitea/models/project"
1516
attachment_model "code.gitea.io/gitea/models/repo"
1617
"code.gitea.io/gitea/models/unit"
@@ -333,7 +334,29 @@ func ViewProject(ctx *context.Context) {
333334
return
334335
}
335336

336-
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns)
337+
var labelIDs []int64
338+
// 1,-2 means including label 1 and excluding label 2
339+
// 0 means issues with no label
340+
// blank means labels will not be filtered for issues
341+
selectLabels := ctx.FormString("labels")
342+
if selectLabels == "" {
343+
ctx.Data["AllLabels"] = true
344+
} else if selectLabels == "0" {
345+
ctx.Data["NoLabel"] = true
346+
}
347+
if len(selectLabels) > 0 {
348+
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
349+
if err != nil {
350+
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
351+
}
352+
}
353+
354+
assigneeID := ctx.FormInt64("assignee")
355+
356+
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
357+
LabelIDs: labelIDs,
358+
AssigneeID: assigneeID,
359+
})
337360
if err != nil {
338361
ctx.ServerError("LoadIssuesOfColumns", err)
339362
return
@@ -372,6 +395,46 @@ func ViewProject(ctx *context.Context) {
372395
}
373396
}
374397

398+
// TODO: Add option to filter also by repository specific labels
399+
labels, err := issues_model.GetLabelsByOrgID(ctx, project.OwnerID, "", db.ListOptions{})
400+
if err != nil {
401+
ctx.ServerError("GetLabelsByOrgID", err)
402+
return
403+
}
404+
405+
// Get the exclusive scope for every label ID
406+
labelExclusiveScopes := make([]string, 0, len(labelIDs))
407+
for _, labelID := range labelIDs {
408+
foundExclusiveScope := false
409+
for _, label := range labels {
410+
if label.ID == labelID || label.ID == -labelID {
411+
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
412+
foundExclusiveScope = true
413+
break
414+
}
415+
}
416+
if !foundExclusiveScope {
417+
labelExclusiveScopes = append(labelExclusiveScopes, "")
418+
}
419+
}
420+
421+
for _, l := range labels {
422+
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
423+
}
424+
ctx.Data["Labels"] = labels
425+
ctx.Data["NumLabels"] = len(labels)
426+
427+
// Get assignees.
428+
assigneeUsers, err := org_model.GetOrgAssignees(ctx, project.OwnerID)
429+
if err != nil {
430+
ctx.ServerError("GetRepoAssignees", err)
431+
return
432+
}
433+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
434+
435+
ctx.Data["SelectLabels"] = selectLabels
436+
ctx.Data["AssigneeID"] = assigneeID
437+
375438
project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description)
376439
ctx.Data["LinkedPRs"] = linkedPrsMap
377440
ctx.Data["PageIsViewProjects"] = true

routers/web/repo/actions/actions.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
"code.gitea.io/gitea/modules/optional"
2424
"code.gitea.io/gitea/modules/setting"
2525
"code.gitea.io/gitea/modules/util"
26-
"code.gitea.io/gitea/routers/web/repo"
26+
shared_user "code.gitea.io/gitea/routers/web/shared/user"
2727
"code.gitea.io/gitea/services/context"
2828
"code.gitea.io/gitea/services/convert"
2929

@@ -252,7 +252,7 @@ func List(ctx *context.Context) {
252252
ctx.ServerError("GetActors", err)
253253
return
254254
}
255-
ctx.Data["Actors"] = repo.MakeSelfOnTop(ctx.Doer, actors)
255+
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
256256

257257
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx)
258258

routers/web/repo/helper.go

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,11 @@ package repo
55

66
import (
77
"net/url"
8-
"sort"
98

10-
"code.gitea.io/gitea/models/user"
119
"code.gitea.io/gitea/modules/git"
1210
"code.gitea.io/gitea/services/context"
1311
)
1412

15-
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
16-
if doer != nil {
17-
sort.Slice(users, func(i, j int) bool {
18-
if users[i].ID == users[j].ID {
19-
return false
20-
}
21-
return users[i].ID == doer.ID // if users[i] is self, put it before others, so less=true
22-
})
23-
}
24-
return users
25-
}
26-
2713
func HandleGitError(ctx *context.Context, msg string, err error) {
2814
if git.IsErrNotExist(err) {
2915
refType := ""

routers/web/repo/issue.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import (
4949
"code.gitea.io/gitea/modules/util"
5050
"code.gitea.io/gitea/modules/web"
5151
"code.gitea.io/gitea/routers/utils"
52+
shared_user "code.gitea.io/gitea/routers/web/shared/user"
5253
asymkey_service "code.gitea.io/gitea/services/asymkey"
5354
"code.gitea.io/gitea/services/context"
5455
"code.gitea.io/gitea/services/context/upload"
@@ -360,7 +361,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
360361
ctx.ServerError("GetRepoAssignees", err)
361362
return
362363
}
363-
ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers)
364+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
364365

365366
handleTeamMentions(ctx)
366367
if ctx.Written() {
@@ -580,7 +581,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R
580581
ctx.ServerError("GetRepoAssignees", err)
581582
return
582583
}
583-
ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers)
584+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
584585

585586
handleTeamMentions(ctx)
586587
}
@@ -3771,7 +3772,7 @@ func issuePosters(ctx *context.Context, isPullList bool) {
37713772
}
37723773
}
37733774

3774-
posters = MakeSelfOnTop(ctx.Doer, posters)
3775+
posters = shared_user.MakeSelfOnTop(ctx.Doer, posters)
37753776

37763777
resp := &userSearchResponse{}
37773778
resp.Results = make([]*userSearchInfo, len(posters))

routers/web/repo/projects.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"code.gitea.io/gitea/modules/setting"
2424
"code.gitea.io/gitea/modules/util"
2525
"code.gitea.io/gitea/modules/web"
26+
shared_user "code.gitea.io/gitea/routers/web/shared/user"
2627
"code.gitea.io/gitea/services/context"
2728
"code.gitea.io/gitea/services/forms"
2829
project_service "code.gitea.io/gitea/services/projects"
@@ -313,7 +314,29 @@ func ViewProject(ctx *context.Context) {
313314
return
314315
}
315316

316-
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns)
317+
var labelIDs []int64
318+
// 1,-2 means including label 1 and excluding label 2
319+
// 0 means issues with no label
320+
// blank means labels will not be filtered for issues
321+
selectLabels := ctx.FormString("labels")
322+
if selectLabels == "" {
323+
ctx.Data["AllLabels"] = true
324+
} else if selectLabels == "0" {
325+
ctx.Data["NoLabel"] = true
326+
}
327+
if len(selectLabels) > 0 {
328+
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
329+
if err != nil {
330+
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
331+
}
332+
}
333+
334+
assigneeID := ctx.FormInt64("assignee")
335+
336+
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
337+
LabelIDs: labelIDs,
338+
AssigneeID: assigneeID,
339+
})
317340
if err != nil {
318341
ctx.ServerError("LoadIssuesOfColumns", err)
319342
return
@@ -353,6 +376,55 @@ func ViewProject(ctx *context.Context) {
353376
}
354377
ctx.Data["LinkedPRs"] = linkedPrsMap
355378

379+
labels, err := issues_model.GetLabelsByRepoID(ctx, project.RepoID, "", db.ListOptions{})
380+
if err != nil {
381+
ctx.ServerError("GetLabelsByRepoID", err)
382+
return
383+
}
384+
385+
if ctx.Repo.Owner.IsOrganization() {
386+
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, "", db.ListOptions{})
387+
if err != nil {
388+
ctx.ServerError("GetLabelsByOrgID", err)
389+
return
390+
}
391+
392+
labels = append(labels, orgLabels...)
393+
}
394+
395+
// Get the exclusive scope for every label ID
396+
labelExclusiveScopes := make([]string, 0, len(labelIDs))
397+
for _, labelID := range labelIDs {
398+
foundExclusiveScope := false
399+
for _, label := range labels {
400+
if label.ID == labelID || label.ID == -labelID {
401+
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
402+
foundExclusiveScope = true
403+
break
404+
}
405+
}
406+
if !foundExclusiveScope {
407+
labelExclusiveScopes = append(labelExclusiveScopes, "")
408+
}
409+
}
410+
411+
for _, l := range labels {
412+
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
413+
}
414+
ctx.Data["Labels"] = labels
415+
ctx.Data["NumLabels"] = len(labels)
416+
417+
// Get assignees.
418+
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
419+
if err != nil {
420+
ctx.ServerError("GetRepoAssignees", err)
421+
return
422+
}
423+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
424+
425+
ctx.Data["SelectLabels"] = selectLabels
426+
ctx.Data["AssigneeID"] = assigneeID
427+
356428
project.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
357429
Links: markup.Links{
358430
Base: ctx.Repo.RepoLink,

routers/web/repo/pull.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"code.gitea.io/gitea/modules/util"
3535
"code.gitea.io/gitea/modules/web"
3636
"code.gitea.io/gitea/routers/utils"
37+
shared_user "code.gitea.io/gitea/routers/web/shared/user"
3738
asymkey_service "code.gitea.io/gitea/services/asymkey"
3839
"code.gitea.io/gitea/services/automerge"
3940
"code.gitea.io/gitea/services/context"
@@ -825,7 +826,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
825826
ctx.ServerError("GetRepoAssignees", err)
826827
return
827828
}
828-
ctx.Data["Assignees"] = MakeSelfOnTop(ctx.Doer, assigneeUsers)
829+
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
829830

830831
handleTeamMentions(ctx)
831832
if ctx.Written() {

0 commit comments

Comments
 (0)