1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2024-12-21 12:44:49 -05:00

issue search on my related repositories (#9758)

* adding search capability to user's issues dashboard

* global issue search

* placement of search bar on issues dashboard

* fixed some bugs in the issue dashboard search

* added unit test because IssueIDs option was added to UserIssueStatsOptions

* some renaming of fields in the issue dashboard code to be more clear; also trying to fix issue of searching the right repos based on the filter

* added unit test fro GetRepoIDsForIssuesOptions; fixed search lost on pagination; using shown issue status for open/close count; removed some debugging

* fix issue with all count showing incorrectly

* removed todo comment left in by mistake

* typo pulling wrong count

* fxied all count being off when selecting repositories

* setting the opts.IsClosed after pulling repos to search, this is done so that the list of repo ids to serach for the keyword is not limited, we need to get all the issue ids for the shown issue stats

* added "accessibleRepositoryCondition" check on the query to pull the repo ids to search for issues, this is an added protection to ensure we don't search repos the user does not have access to

* added code so that in the issues search, we won't use an in clause of issues ids that goes over 1000

* fixed unit test

* using 950 as the limit for issue search, removed unneeded group by in GetRepoIDsForIssuesOptions, showing search on pulls dashboard page too (not just issues)

Co-authored-by: guillep2k <18600385+guillep2k@users.noreply.github.com>
This commit is contained in:
Brad Albright 2020-02-29 00:52:05 -06:00 committed by GitHub
parent af61b2249a
commit 82be59e633
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 214 additions and 54 deletions

View file

@ -76,6 +76,7 @@ var (
const issueTasksRegexpStr = `(^\s*[-*]\s\[[\sx]\]\s.)|(\n\s*[-*]\s\[[\sx]\]\s.)` const issueTasksRegexpStr = `(^\s*[-*]\s\[[\sx]\]\s.)|(\n\s*[-*]\s\[[\sx]\]\s.)`
const issueTasksDoneRegexpStr = `(^\s*[-*]\s\[[x]\]\s.)|(\n\s*[-*]\s\[[x]\]\s.)` const issueTasksDoneRegexpStr = `(^\s*[-*]\s\[[x]\]\s.)|(\n\s*[-*]\s\[[x]\]\s.)`
const issueMaxDupIndexAttempts = 3 const issueMaxDupIndexAttempts = 3
const maxIssueIDs = 950
func init() { func init() {
issueTasksPat = regexp.MustCompile(issueTasksRegexpStr) issueTasksPat = regexp.MustCompile(issueTasksRegexpStr)
@ -1098,6 +1099,9 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) {
} }
if len(opts.IssueIDs) > 0 { if len(opts.IssueIDs) > 0 {
if len(opts.IssueIDs) > maxIssueIDs {
opts.IssueIDs = opts.IssueIDs[:maxIssueIDs]
}
sess.In("issue.id", opts.IssueIDs) sess.In("issue.id", opts.IssueIDs)
} }
@ -1176,6 +1180,26 @@ func CountIssuesByRepo(opts *IssuesOptions) (map[int64]int64, error) {
return countMap, nil return countMap, nil
} }
// GetRepoIDsForIssuesOptions find all repo ids for the given options
func GetRepoIDsForIssuesOptions(opts *IssuesOptions, user *User) ([]int64, error) {
repoIDs := make([]int64, 0, 5)
sess := x.NewSession()
defer sess.Close()
opts.setupSession(sess)
accessCond := accessibleRepositoryCondition(user)
if err := sess.Where(accessCond).
Join("INNER", "repository", "`issue`.repo_id = `repository`.id").
Distinct("issue.repo_id").
Table("issue").
Find(&repoIDs); err != nil {
return nil, err
}
return repoIDs, nil
}
// Issues returns a list of issues by given conditions. // Issues returns a list of issues by given conditions.
func Issues(opts *IssuesOptions) ([]*Issue, error) { func Issues(opts *IssuesOptions) ([]*Issue, error) {
sess := x.NewSession() sess := x.NewSession()
@ -1313,6 +1337,9 @@ func getIssueStatsChunk(opts *IssueStatsOptions, issueIDs []int64) (*IssueStats,
Where("issue.repo_id = ?", opts.RepoID) Where("issue.repo_id = ?", opts.RepoID)
if len(opts.IssueIDs) > 0 { if len(opts.IssueIDs) > 0 {
if len(opts.IssueIDs) > maxIssueIDs {
opts.IssueIDs = opts.IssueIDs[:maxIssueIDs]
}
sess.In("issue.id", opts.IssueIDs) sess.In("issue.id", opts.IssueIDs)
} }
@ -1382,6 +1409,7 @@ type UserIssueStatsOptions struct {
FilterMode int FilterMode int
IsPull bool IsPull bool
IsClosed bool IsClosed bool
IssueIDs []int64
} }
// GetUserIssueStats returns issue statistic information for dashboard by given conditions. // GetUserIssueStats returns issue statistic information for dashboard by given conditions.
@ -1394,6 +1422,12 @@ func GetUserIssueStats(opts UserIssueStatsOptions) (*IssueStats, error) {
if len(opts.RepoIDs) > 0 { if len(opts.RepoIDs) > 0 {
cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs)) cond = cond.And(builder.In("issue.repo_id", opts.RepoIDs))
} }
if len(opts.IssueIDs) > 0 {
if len(opts.IssueIDs) > maxIssueIDs {
opts.IssueIDs = opts.IssueIDs[:maxIssueIDs]
}
cond = cond.And(builder.In("issue.id", opts.IssueIDs))
}
switch opts.FilterMode { switch opts.FilterMode {
case FilterModeAll: case FilterModeAll:

View file

@ -253,6 +253,20 @@ func TestGetUserIssueStats(t *testing.T) {
ClosedCount: 0, ClosedCount: 0,
}, },
}, },
{
UserIssueStatsOptions{
UserID: 1,
FilterMode: FilterModeCreate,
IssueIDs: []int64{1},
},
IssueStats{
YourRepositoriesCount: 0,
AssignCount: 1,
CreateCount: 1,
OpenCount: 1,
ClosedCount: 0,
},
},
} { } {
stats, err := GetUserIssueStats(test.Opts) stats, err := GetUserIssueStats(test.Opts)
if !assert.NoError(t, err) { if !assert.NoError(t, err) {
@ -294,6 +308,36 @@ func TestIssue_SearchIssueIDsByKeyword(t *testing.T) {
assert.EqualValues(t, []int64{1}, ids) assert.EqualValues(t, []int64{1}, ids)
} }
func TestGetRepoIDsForIssuesOptions(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
for _, test := range []struct {
Opts IssuesOptions
ExpectedRepoIDs []int64
}{
{
IssuesOptions{
AssigneeID: 2,
},
[]int64{3},
},
{
IssuesOptions{
RepoIDs: []int64{1, 2},
},
[]int64{1, 2},
},
} {
repoIDs, err := GetRepoIDsForIssuesOptions(&test.Opts, user)
assert.NoError(t, err)
if assert.Len(t, repoIDs, len(test.ExpectedRepoIDs)) {
for i, repoID := range repoIDs {
assert.EqualValues(t, test.ExpectedRepoIDs[i], repoID)
}
}
}
}
func testInsertIssue(t *testing.T, title, content string) { func testInsertIssue(t *testing.T, title, content string) {
repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)

View file

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -449,7 +450,6 @@ func Issues(ctx *context.Context) {
} }
opts := &models.IssuesOptions{ opts := &models.IssuesOptions{
IsClosed: util.OptionalBoolOf(isShowClosed),
IsPull: util.OptionalBoolOf(isPullList), IsPull: util.OptionalBoolOf(isPullList),
SortType: sortType, SortType: sortType,
} }
@ -465,10 +465,39 @@ func Issues(ctx *context.Context) {
opts.MentionedID = ctxUser.ID opts.MentionedID = ctxUser.ID
} }
counts, err := models.CountIssuesByRepo(opts) var forceEmpty bool
if err != nil { var issueIDsFromSearch []int64
ctx.ServerError("CountIssuesByRepo", err) var keyword = strings.Trim(ctx.Query("q"), " ")
return
if len(keyword) > 0 {
searchRepoIDs, err := models.GetRepoIDsForIssuesOptions(opts, ctxUser)
if err != nil {
ctx.ServerError("GetRepoIDsForIssuesOptions", err)
return
}
issueIDsFromSearch, err = issue_indexer.SearchIssuesByKeyword(searchRepoIDs, keyword)
if err != nil {
ctx.ServerError("SearchIssuesByKeyword", err)
return
}
if len(issueIDsFromSearch) > 0 {
opts.IssueIDs = issueIDsFromSearch
} else {
forceEmpty = true
}
}
ctx.Data["Keyword"] = keyword
opts.IsClosed = util.OptionalBoolOf(isShowClosed)
var counts map[int64]int64
if !forceEmpty {
counts, err = models.CountIssuesByRepo(opts)
if err != nil {
ctx.ServerError("CountIssuesByRepo", err)
return
}
} }
opts.Page = page opts.Page = page
@ -488,10 +517,15 @@ func Issues(ctx *context.Context) {
opts.RepoIDs = repoIDs opts.RepoIDs = repoIDs
} }
issues, err := models.Issues(opts) var issues []*models.Issue
if err != nil { if !forceEmpty {
ctx.ServerError("Issues", err) issues, err = models.Issues(opts)
return if err != nil {
ctx.ServerError("Issues", err)
return
}
} else {
issues = []*models.Issue{}
} }
showReposMap := make(map[int64]*models.Repository, len(counts)) showReposMap := make(map[int64]*models.Repository, len(counts))
@ -538,7 +572,7 @@ func Issues(ctx *context.Context) {
} }
} }
issueStatsOpts := models.UserIssueStatsOptions{ userIssueStatsOpts := models.UserIssueStatsOptions{
UserID: ctxUser.ID, UserID: ctxUser.ID,
UserRepoIDs: userRepoIDs, UserRepoIDs: userRepoIDs,
FilterMode: filterMode, FilterMode: filterMode,
@ -546,33 +580,61 @@ func Issues(ctx *context.Context) {
IsClosed: isShowClosed, IsClosed: isShowClosed,
} }
if len(repoIDs) > 0 { if len(repoIDs) > 0 {
issueStatsOpts.UserRepoIDs = repoIDs userIssueStatsOpts.UserRepoIDs = repoIDs
} }
issueStats, err := models.GetUserIssueStats(issueStatsOpts) userIssueStats, err := models.GetUserIssueStats(userIssueStatsOpts)
if err != nil { if err != nil {
ctx.ServerError("GetUserIssueStats", err) ctx.ServerError("GetUserIssueStats User", err)
return return
} }
allIssueStats, err := models.GetUserIssueStats(models.UserIssueStatsOptions{ var shownIssueStats *models.IssueStats
UserID: ctxUser.ID, if !forceEmpty {
UserRepoIDs: userRepoIDs, statsOpts := models.UserIssueStatsOptions{
FilterMode: filterMode, UserID: ctxUser.ID,
IsPull: isPullList, UserRepoIDs: userRepoIDs,
IsClosed: isShowClosed, FilterMode: filterMode,
}) IsPull: isPullList,
if err != nil { IsClosed: isShowClosed,
ctx.ServerError("GetUserIssueStats All", err) IssueIDs: issueIDsFromSearch,
return }
if len(repoIDs) > 0 {
statsOpts.RepoIDs = repoIDs
}
shownIssueStats, err = models.GetUserIssueStats(statsOpts)
if err != nil {
ctx.ServerError("GetUserIssueStats Shown", err)
return
}
} else {
shownIssueStats = &models.IssueStats{}
}
var allIssueStats *models.IssueStats
if !forceEmpty {
allIssueStats, err = models.GetUserIssueStats(models.UserIssueStatsOptions{
UserID: ctxUser.ID,
UserRepoIDs: userRepoIDs,
FilterMode: filterMode,
IsPull: isPullList,
IsClosed: isShowClosed,
IssueIDs: issueIDsFromSearch,
})
if err != nil {
ctx.ServerError("GetUserIssueStats All", err)
return
}
} else {
allIssueStats = &models.IssueStats{}
} }
var shownIssues int var shownIssues int
var totalIssues int var totalIssues int
if !isShowClosed { if !isShowClosed {
shownIssues = int(issueStats.OpenCount) shownIssues = int(shownIssueStats.OpenCount)
totalIssues = int(allIssueStats.OpenCount) totalIssues = int(allIssueStats.OpenCount)
} else { } else {
shownIssues = int(issueStats.ClosedCount) shownIssues = int(shownIssueStats.ClosedCount)
totalIssues = int(allIssueStats.ClosedCount) totalIssues = int(allIssueStats.ClosedCount)
} }
@ -580,7 +642,8 @@ func Issues(ctx *context.Context) {
ctx.Data["CommitStatus"] = commitStatus ctx.Data["CommitStatus"] = commitStatus
ctx.Data["Repos"] = showRepos ctx.Data["Repos"] = showRepos
ctx.Data["Counts"] = counts ctx.Data["Counts"] = counts
ctx.Data["IssueStats"] = issueStats ctx.Data["IssueStats"] = userIssueStats
ctx.Data["ShownIssueStats"] = shownIssueStats
ctx.Data["ViewType"] = viewType ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType ctx.Data["SortType"] = sortType
ctx.Data["RepoIDs"] = repoIDs ctx.Data["RepoIDs"] = repoIDs
@ -599,6 +662,7 @@ func Issues(ctx *context.Context) {
ctx.Data["ReposParam"] = string(reposParam) ctx.Data["ReposParam"] = string(reposParam)
pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5) pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
pager.AddParam(ctx, "q", "Keyword")
pager.AddParam(ctx, "type", "ViewType") pager.AddParam(ctx, "type", "ViewType")
pager.AddParam(ctx, "repos", "ReposParam") pager.AddParam(ctx, "repos", "ReposParam")
pager.AddParam(ctx, "sort", "SortType") pager.AddParam(ctx, "sort", "SortType")

View file

@ -24,7 +24,7 @@
</a> </a>
{{end}} {{end}}
<div class="ui divider"></div> <div class="ui divider"></div>
<a class="{{if not $.RepoIDs}}ui basic blue button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}"> <a class="{{if not $.RepoIDs}}ui basic blue button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&q={{$.Keyword}}">
<span class="text truncate">All</span> <span class="text truncate">All</span>
<div class="ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{.TotalIssueCount}}</div> <div class="ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{.TotalIssueCount}}</div>
</a> </a>
@ -43,7 +43,7 @@
{{$Repo.ID}}%2C {{$Repo.ID}}%2C
{{end}} {{end}}
{{end}} {{end}}
]&sort={{$.SortType}}&state={{$.State}}" title="{{.FullName}}"> ]&sort={{$.SortType}}&state={{$.State}}&q={{$.Keyword}}" title="{{.FullName}}">
<span class="text truncate">{{$Repo.FullName}}</span> <span class="text truncate">{{$Repo.FullName}}</span>
<div class="ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{index $.Counts $Repo.ID}}</div> <div class="ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{index $.Counts $Repo.ID}}</div>
</a> </a>
@ -52,32 +52,50 @@
</div> </div>
</div> </div>
<div class="twelve wide column content"> <div class="twelve wide column content">
<div class="ui tiny basic status buttons"> <div class="ui three column stackable grid">
<a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open"> <div class="column">
{{svg "octicon-issue-opened" 16}} <div class="ui tiny basic status buttons">
{{.i18n.Tr "repo.issues.open_tab" .IssueStats.OpenCount}} <a class="ui {{if not .IsShowClosed}}green active{{end}} basic button" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
</a> {{svg "octicon-issue-opened" 16}}
<a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed"> {{.i18n.Tr "repo.issues.open_tab" .ShownIssueStats.OpenCount}}
{{svg "octicon-issue-closed" 16}} </a>
{{.i18n.Tr "repo.issues.close_tab" .IssueStats.ClosedCount}} <a class="ui {{if .IsShowClosed}}red active{{end}} basic button" href="{{.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
</a> {{svg "octicon-issue-closed" 16}}
</div> {{.i18n.Tr "repo.issues.close_tab" .ShownIssueStats.ClosedCount}}
<div class="ui right floated secondary filter menu"> </a>
<!-- Sort --> </div>
<div class="ui dropdown type jump item"> </div>
<span class="text"> <div class="column center aligned">
{{.i18n.Tr "repo.issues.filter_sort"}} <form class="ui form ignore-dirty">
<i class="dropdown icon"></i> <div class="ui fluid action input">
</span> <input type="hidden" name="type" value="{{$.ViewType}}"/>
<div class="menu"> <input type="hidden" name="repos" value="[{{range $.RepoIDs}}{{.}}%2C{{end}}]"/>
<a class="{{if or (eq .SortType "latest") (not .SortType)}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=latest&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.latest"}}</a> <input type="hidden" name="sort" value="{{$.SortType}}"/>
<a class="{{if eq .SortType "oldest"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=oldest&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</a> <input type="hidden" name="state" value="{{$.State}}"/>
<a class="{{if eq .SortType "recentupdate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=recentupdate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</a> <div class="ui search action input">
<a class="{{if eq .SortType "leastupdate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastupdate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</a> <input name="q" value="{{$.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}..." autofocus>
<a class="{{if eq .SortType "mostcomment"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomment&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.mostcomment"}}</a> </div>
<a class="{{if eq .SortType "leastcomment"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomment&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.leastcomment"}}</a> <button class="ui blue button" type="submit">{{.i18n.Tr "explore.search"}}</button>
<a class="{{if eq .SortType "nearduedate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=nearduedate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.nearduedate"}}</a> </div>
<a class="{{if eq .SortType "farduedate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=farduedate&state={{$.State}}">{{.i18n.Tr "repo.issues.filter_sort.farduedate"}}</a> </form>
</div>
<div class="column right aligned">
<!-- Sort -->
<div class="ui dropdown type jump item">
<span class="text">
{{.i18n.Tr "repo.issues.filter_sort"}}
<i class="dropdown icon"></i>
</span>
<div class="menu">
<a class="{{if or (eq .SortType "latest") (not .SortType)}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=latest&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.latest"}}</a>
<a class="{{if eq .SortType "oldest"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=oldest&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</a>
<a class="{{if eq .SortType "recentupdate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=recentupdate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</a>
<a class="{{if eq .SortType "leastupdate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastupdate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</a>
<a class="{{if eq .SortType "mostcomment"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=mostcomment&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.mostcomment"}}</a>
<a class="{{if eq .SortType "leastcomment"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=leastcomment&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.leastcomment"}}</a>
<a class="{{if eq .SortType "nearduedate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=nearduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.nearduedate"}}</a>
<a class="{{if eq .SortType "farduedate"}}active{{end}} item" href="{{$.Link}}?type={{$.ViewType}}&repos=[{{range $.RepoIDs}}{{.}}%2C{{end}}]&sort=farduedate&state={{$.State}}&q={{$.Keyword}}">{{.i18n.Tr "repo.issues.filter_sort.farduedate"}}</a>
</div>
</div> </div>
</div> </div>
</div> </div>