1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-03 09:07:19 +00:00

Add label/author/assignee filters to the user/org home issue list (#32779)

Replace #26661, fix #25979

Not perfect, but usable and much better than before. Since it is quite
complex, I am not quite sure whether there would be any regression, if
any, I will fix in first time.

I have tested the related pages many times: issue list, milestone issue
list, project view, user issue list, org issue list.
This commit is contained in:
wxiaoguang
2024-12-11 14:33:24 +08:00
committed by GitHub
parent 734ddf7118
commit e619384098
27 changed files with 338 additions and 319 deletions

View File

@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
@ -334,23 +335,15 @@ func ViewProject(ctx *context.Context) {
return
}
var labelIDs []int64
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner)
if ctx.Written() {
return
}
assigneeID := ctx.FormInt64("assignee")
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
LabelIDs: labelIDs,
AssigneeID: assigneeID,
AssigneeID: optional.Some(assigneeID),
})
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
@ -426,8 +419,6 @@ func ViewProject(ctx *context.Context) {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)

View File

@ -17,12 +17,12 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
@ -263,8 +263,10 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
return user.ID
}
// ListIssues list the issues of a repository
func ListIssues(ctx *context.Context) {
// SearchRepoIssuesJSON lists the issues of a repository
// This function was copied from API (decouple the web and API routes),
// it is only used by frontend to search some dependency or related issues
func SearchRepoIssuesJSON(ctx *context.Context) {
before, since, err := context.GetQueryBeforeSince(ctx.Base)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, err.Error())
@ -286,20 +288,11 @@ func ListIssues(ctx *context.Context) {
keyword = ""
}
var labelIDs []int64
if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 {
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, splitted)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error())
return
}
}
var mileIDs []int64
if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 {
for i := range part {
// uses names and fall back to ids
// non existent milestones are discarded
// non-existent milestones are discarded
mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i])
if err == nil {
mileIDs = append(mileIDs, mile.ID)
@ -370,17 +363,8 @@ func ListIssues(ctx *context.Context) {
if before != 0 {
searchOpt.UpdatedBeforeUnix = optional.Some(before)
}
if len(labelIDs) == 1 && labelIDs[0] == 0 {
searchOpt.NoLabelOnly = true
} else {
for _, labelID := range labelIDs {
if labelID > 0 {
searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID)
} else {
searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID)
}
}
}
// TODO: the "labels" query parameter is never used, so no need to handle it
if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
searchOpt.MilestoneIDs = []int64{0}
@ -503,8 +487,8 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if !util.SliceContainsString(types, viewType, true) {
viewType = "all"
}
// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
assigneeID := ctx.FormInt64("assignee")
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
posterUsername := ctx.FormString("poster")
posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername)
var mentionedID, reviewRequestedID, reviewedID int64
@ -512,7 +496,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
if ctx.IsSigned {
switch viewType {
case "created_by":
posterUserID = ctx.Doer.ID
posterUserID = optional.Some(ctx.Doer.ID)
case "mentioned":
mentionedID = ctx.Doer.ID
case "assigned":
@ -525,18 +509,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
}
repo := ctx.Repo.Repository
var labelIDs []int64
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
keyword := strings.Trim(ctx.FormString("q"), " ")
if bytes.Contains([]byte(keyword), []byte{0x00}) {
keyword = ""
@ -547,13 +519,18 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
mileIDs = []int64{milestoneID}
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner)
if ctx.Written() {
return
}
var issueStats *issues_model.IssueStats
statsOpts := &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
LabelIDs: labelIDs,
MilestoneIDs: mileIDs,
ProjectID: projectID,
AssigneeID: assigneeID,
AssigneeID: optional.Some(assigneeID),
MentionedID: mentionedID,
PosterID: posterUserID,
ReviewRequestedID: reviewRequestedID,
@ -634,7 +611,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
PageSize: setting.UI.IssuePagingNum,
},
RepoIDs: []int64{repo.ID},
AssigneeID: assigneeID,
AssigneeID: optional.Some(assigneeID),
PosterID: posterUserID,
MentionedID: mentionedID,
ReviewRequestedID: reviewRequestedID,
@ -709,49 +686,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
return
}
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
if repo.Owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
ctx.Data["OrgLabels"] = orgLabels
labels = append(labels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
ctx.Data["NumLabels"] = len(labels)
if ctx.FormInt64("assignee") == 0 {
assigneeID = 0 // Reset ID to prevent unexpected selection of assignee.
}
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)
ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
@ -792,13 +726,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
ctx.Data["OpenCount"] = issueStats.OpenCount
ctx.Data["ClosedCount"] = issueStats.ClosedCount
ctx.Data["SelLabelIDs"] = labelIDs
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["MilestoneID"] = milestoneID
ctx.Data["ProjectID"] = projectID
ctx.Data["AssigneeID"] = assigneeID
ctx.Data["PosterUserID"] = posterUserID
ctx.Data["PosterUsername"] = posterUsername
ctx.Data["Keyword"] = keyword
ctx.Data["IsShowClosed"] = isShowClosed
@ -810,19 +742,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
default:
ctx.Data["State"] = "open"
}
pager.AddParamString("q", keyword)
pager.AddParamString("type", viewType)
pager.AddParamString("sort", sortType)
pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
pager.AddParamString("labels", fmt.Sprint(selectLabels))
pager.AddParamString("milestone", fmt.Sprint(milestoneID))
pager.AddParamString("project", fmt.Sprint(projectID))
pager.AddParamString("assignee", fmt.Sprint(assigneeID))
pager.AddParamString("poster", posterUsername)
if showArchivedLabels {
pager.AddParamString("archived_labels", "true")
}
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
}

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/issue"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
@ -307,23 +308,13 @@ func ViewProject(ctx *context.Context) {
return
}
var labelIDs []int64
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner)
assigneeID := ctx.FormInt64("assignee")
assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future
issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{
LabelIDs: labelIDs,
AssigneeID: assigneeID,
AssigneeID: optional.Some(assigneeID),
})
if err != nil {
ctx.ServerError("LoadIssuesOfColumns", err)
@ -409,8 +400,6 @@ func ViewProject(ctx *context.Context) {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["SelectLabels"] = selectLabels
ctx.Data["AssigneeID"] = assigneeID
rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository)

View File

@ -0,0 +1,71 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issue
import (
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/services/context"
)
// PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) {
// 1,-2 means including label 1 and excluding label 2
// 0 means issues with no label
// blank means labels will not be filtered for issues
selectLabels := ctx.FormString("labels")
if selectLabels != "" {
var err error
labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ","))
if err != nil {
ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true)
}
}
var allLabels []*issues_model.Label
if repoID != 0 {
repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return nil
}
allLabels = append(allLabels, repoLabels...)
}
if owner != nil && owner.IsOrganization() {
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return nil
}
allLabels = append(allLabels, orgLabels...)
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(labelIDs))
for _, labelID := range labelIDs {
foundExclusiveScope := false
for _, label := range allLabels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range allLabels {
l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = allLabels
ctx.Data["SelectLabels"] = selectLabels
return labelIDs
}

View File

@ -8,7 +8,9 @@ import (
"slices"
"strconv"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
)
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
@ -31,17 +33,20 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
// So it's better to make it work like GitHub: users could input username directly.
// Since it only converts the username to ID directly and is only used internally (to search issues), so no permission check is needed.
// Old usage: poster=123, new usage: poster=the-username (at the moment, non-existing username is treated as poster=0, not ideal but acceptable)
func GetFilterUserIDByName(ctx context.Context, name string) int64 {
// Return values:
// * nil: no filter
// * some(id): match the id, the id could be -1 to match the issues without assignee
// * some(NonExistingID): match no issue (due to the user doesn't exist)
func GetFilterUserIDByName(ctx context.Context, name string) optional.Option[int64] {
if name == "" {
return 0
return optional.None[int64]()
}
u, err := user.GetUserByName(ctx, name)
if err != nil {
if id, err := strconv.ParseInt(name, 10, 64); err == nil {
return id
return optional.Some(id)
}
return 0
return optional.Some(db.NonExistingID)
}
return u.ID
return optional.Some(u.ID)
}

View File

@ -33,6 +33,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/routers/web/shared/issue"
"code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
feed_service "code.gitea.io/gitea/services/feed"
@ -413,6 +414,13 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
viewType = "your_repositories"
}
isPullList := unitType == unit.TypePullRequests
opts := &issues_model.IssuesOptions{
IsPull: optional.Some(isPullList),
SortType: sortType,
IsArchived: optional.Some(false),
User: ctx.Doer,
}
// --------------------------------------------------------------------------
// Build opts (IssuesOptions), which contains filter information.
// Will eventually be used to retrieve issues relevant for the overview page.
@ -422,30 +430,24 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// --------------------------------------------------------------------------
// Get repository IDs where User/Org/Team has access.
var team *organization.Team
var org *organization.Organization
if ctx.Org != nil {
org = ctx.Org.Organization
team = ctx.Org.Team
}
if ctx.Org != nil && ctx.Org.Organization != nil {
opts.Org = ctx.Org.Organization
opts.Team = ctx.Org.Team
isPullList := unitType == unit.TypePullRequests
opts := &issues_model.IssuesOptions{
IsPull: optional.Some(isPullList),
SortType: sortType,
IsArchived: optional.Some(false),
Org: org,
Team: team,
User: ctx.Doer,
issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser())
if ctx.Written() {
return
}
}
// Get filter by author id & assignee id
// FIXME: this feature doesn't work at the moment, because frontend can't use a "user-remote-search" dropdown directly
// the existing "/posters" handlers doesn't work for this case, it is unable to list the related users correctly.
// In the future, we need something like github: "author:user1" to accept usernames directly.
posterUsername := ctx.FormString("poster")
ctx.Data["FilterPosterUsername"] = posterUsername
opts.PosterID = user.GetFilterUserIDByName(ctx, posterUsername)
// TODO: "assignee" should also use GetFilterUserIDByName in the future to support usernames directly
opts.AssigneeID, _ = strconv.ParseInt(ctx.FormString("assignee"), 10, 64)
assigneeUsername := ctx.FormString("assignee")
ctx.Data["FilterAssigneeUsername"] = assigneeUsername
opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
isFuzzy := ctx.FormBool("fuzzy")
@ -471,8 +473,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
UnitType: unitType,
Archived: optional.Some(false),
}
if team != nil {
repoOpts.TeamID = team.ID
if opts.Team != nil {
repoOpts.TeamID = opts.Team.ID
}
accessibleRepos := container.Set[int64]{}
{
@ -500,9 +502,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
case issues_model.FilterModeAll:
case issues_model.FilterModeYourRepositories:
case issues_model.FilterModeAssign:
opts.AssigneeID = ctx.Doer.ID
opts.AssigneeID = optional.Some(ctx.Doer.ID)
case issues_model.FilterModeCreate:
opts.PosterID = ctx.Doer.ID
opts.PosterID = optional.Some(ctx.Doer.ID)
case issues_model.FilterModeMention:
opts.MentionedID = ctx.Doer.ID
case issues_model.FilterModeReviewRequested:
@ -584,10 +586,6 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// because the doer may create issues or be mentioned in any public repo.
// So we need search issues in all public repos.
o.AllPublic = ctx.Doer.ID == ctxUser.ID
// TODO: to make it work with poster/assignee filter, then these IDs should be kept
o.AssigneeID = nil
o.PosterID = nil
o.MentionID = nil
o.ReviewRequestedID = nil
o.ReviewedID = nil
@ -645,10 +643,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["IsFuzzy"] = isFuzzy
ctx.Data["SearchFilterPosterID"] = util.Iif[any](opts.PosterID != 0, opts.PosterID, nil)
ctx.Data["SearchFilterAssigneeID"] = util.Iif[any](opts.AssigneeID != 0, opts.AssigneeID, nil)
if isShowClosed {
ctx.Data["State"] = "closed"
@ -657,16 +652,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
}
pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
pager.AddParamString("q", keyword)
pager.AddParamString("type", viewType)
pager.AddParamString("sort", sortType)
pager.AddParamString("state", fmt.Sprint(ctx.Data["State"]))
pager.AddParamString("labels", selectedLabels)
pager.AddParamString("fuzzy", fmt.Sprint(isFuzzy))
pager.AddParamString("poster", posterUsername)
if opts.AssigneeID != 0 {
pager.AddParamString("assignee", fmt.Sprint(opts.AssigneeID))
}
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplIssues)

View File

@ -1208,7 +1208,7 @@ func registerRoutes(m *web.Router) {
Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost)
m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate)
})
m.Get("/search", repo.ListIssues)
m.Get("/search", repo.SearchRepoIssuesJSON)
}, context.RepoMustNotBeArchived(), reqRepoIssueReader)
// FIXME: should use different URLs but mostly same logic for comments of issue and pull request.