* Fix assigned/created issues in dashboard. (#3560) * Fix assigned/created issues in dashboard. * Use GetUserIssueStats for getting all Dashboard stats. * Use gofmt to format the file properly. * Replace &Issue{} with new(Issue). * Check if user has access to given repository. * Remove unnecessary filtering of issues. * Return 404 error if invalid repository is given. * Use correct number of issues in paginater. * fix issues on dashboardtags/v1.21.12.1
| @@ -1184,7 +1184,7 @@ func UpdateIssueMentions(e Engine, issueID int64, mentions []string) error { | |||||
| // IssueStats represents issue statistic information. | // IssueStats represents issue statistic information. | ||||
| type IssueStats struct { | type IssueStats struct { | ||||
| OpenCount, ClosedCount int64 | OpenCount, ClosedCount int64 | ||||
| AllCount int64 | |||||
| YourRepositoriesCount int64 | |||||
| AssignCount int64 | AssignCount int64 | ||||
| CreateCount int64 | CreateCount int64 | ||||
| MentionCount int64 | MentionCount int64 | ||||
| @@ -1210,6 +1210,7 @@ func parseCountResult(results []map[string][]byte) int64 { | |||||
| // IssueStatsOptions contains parameters accepted by GetIssueStats. | // IssueStatsOptions contains parameters accepted by GetIssueStats. | ||||
| type IssueStatsOptions struct { | type IssueStatsOptions struct { | ||||
| FilterMode int | |||||
| RepoID int64 | RepoID int64 | ||||
| Labels string | Labels string | ||||
| MilestoneID int64 | MilestoneID int64 | ||||
| @@ -1265,19 +1266,41 @@ func GetIssueStats(opts *IssueStatsOptions) (*IssueStats, error) { | |||||
| } | } | ||||
| var err error | var err error | ||||
| stats.OpenCount, err = countSession(opts). | |||||
| And("is_closed = ?", false). | |||||
| Count(&Issue{}) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| stats.ClosedCount, err = countSession(opts). | |||||
| And("is_closed = ?", true). | |||||
| Count(&Issue{}) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| switch opts.FilterMode { | |||||
| case FilterModeAll, FilterModeAssign: | |||||
| stats.OpenCount, err = countSession(opts). | |||||
| And("is_closed = ?", false). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, err = countSession(opts). | |||||
| And("is_closed = ?", true). | |||||
| Count(new(Issue)) | |||||
| case FilterModeCreate: | |||||
| stats.OpenCount, err = countSession(opts). | |||||
| And("poster_id = ?", opts.PosterID). | |||||
| And("is_closed = ?", false). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, err = countSession(opts). | |||||
| And("poster_id = ?", opts.PosterID). | |||||
| And("is_closed = ?", true). | |||||
| Count(new(Issue)) | |||||
| case FilterModeMention: | |||||
| stats.OpenCount, err = countSession(opts). | |||||
| Join("INNER", "issue_user", "issue.id = issue_user.issue_id"). | |||||
| And("issue_user.uid = ?", opts.PosterID). | |||||
| And("issue_user.is_mentioned = ?", true). | |||||
| And("issue.is_closed = ?", false). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, err = countSession(opts). | |||||
| Join("INNER", "issue_user", "issue.id = issue_user.issue_id"). | |||||
| And("issue_user.uid = ?", opts.PosterID). | |||||
| And("issue_user.is_mentioned = ?", true). | |||||
| And("issue.is_closed = ?", true). | |||||
| Count(new(Issue)) | |||||
| } | } | ||||
| return stats, nil | |||||
| return stats, err | |||||
| } | } | ||||
| // GetUserIssueStats returns issue statistic information for dashboard by given conditions. | // GetUserIssueStats returns issue statistic information for dashboard by given conditions. | ||||
| @@ -1298,29 +1321,39 @@ func GetUserIssueStats(repoID, uid int64, repoIDs []int64, filterMode int, isPul | |||||
| return sess | return sess | ||||
| } | } | ||||
| stats.AssignCount, _ = countSession(false, isPull, repoID, repoIDs). | |||||
| stats.AssignCount, _ = countSession(false, isPull, repoID, nil). | |||||
| And("assignee_id = ?", uid). | And("assignee_id = ?", uid). | ||||
| Count(&Issue{}) | |||||
| Count(new(Issue)) | |||||
| stats.CreateCount, _ = countSession(false, isPull, repoID, repoIDs). | |||||
| stats.CreateCount, _ = countSession(false, isPull, repoID, nil). | |||||
| And("poster_id = ?", uid). | And("poster_id = ?", uid). | ||||
| Count(&Issue{}) | |||||
| Count(new(Issue)) | |||||
| openCountSession := countSession(false, isPull, repoID, repoIDs) | |||||
| closedCountSession := countSession(true, isPull, repoID, repoIDs) | |||||
| stats.YourRepositoriesCount, _ = countSession(false, isPull, repoID, repoIDs). | |||||
| Count(new(Issue)) | |||||
| switch filterMode { | switch filterMode { | ||||
| case FilterModeAll: | |||||
| stats.OpenCount, _ = countSession(false, isPull, repoID, repoIDs). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, _ = countSession(true, isPull, repoID, repoIDs). | |||||
| Count(new(Issue)) | |||||
| case FilterModeAssign: | case FilterModeAssign: | ||||
| openCountSession.And("assignee_id = ?", uid) | |||||
| closedCountSession.And("assignee_id = ?", uid) | |||||
| stats.OpenCount, _ = countSession(false, isPull, repoID, nil). | |||||
| And("assignee_id = ?", uid). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, _ = countSession(true, isPull, repoID, nil). | |||||
| And("assignee_id = ?", uid). | |||||
| Count(new(Issue)) | |||||
| case FilterModeCreate: | case FilterModeCreate: | ||||
| openCountSession.And("poster_id = ?", uid) | |||||
| closedCountSession.And("poster_id = ?", uid) | |||||
| stats.OpenCount, _ = countSession(false, isPull, repoID, nil). | |||||
| And("poster_id = ?", uid). | |||||
| Count(new(Issue)) | |||||
| stats.ClosedCount, _ = countSession(true, isPull, repoID, nil). | |||||
| And("poster_id = ?", uid). | |||||
| Count(new(Issue)) | |||||
| } | } | ||||
| stats.OpenCount, _ = openCountSession.Count(&Issue{}) | |||||
| stats.ClosedCount, _ = closedCountSession.Count(&Issue{}) | |||||
| return stats | return stats | ||||
| } | } | ||||
| @@ -1347,8 +1380,8 @@ func GetRepoIssueStats(repoID, uid int64, filterMode int, isPull bool) (numOpen | |||||
| closedCountSession.And("poster_id = ?", uid) | closedCountSession.And("poster_id = ?", uid) | ||||
| } | } | ||||
| openResult, _ := openCountSession.Count(&Issue{}) | |||||
| closedResult, _ := closedCountSession.Count(&Issue{}) | |||||
| openResult, _ := openCountSession.Count(new(Issue)) | |||||
| closedResult, _ := closedCountSession.Count(new(Issue)) | |||||
| return openResult, closedResult | return openResult, closedResult | ||||
| } | } | ||||
| @@ -10,7 +10,6 @@ import ( | |||||
| "fmt" | "fmt" | ||||
| "io" | "io" | ||||
| "io/ioutil" | "io/ioutil" | ||||
| "net/url" | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -108,37 +107,17 @@ func Issues(ctx *context.Context) { | |||||
| viewType := ctx.Query("type") | viewType := ctx.Query("type") | ||||
| sortType := ctx.Query("sort") | sortType := ctx.Query("sort") | ||||
| types := []string{"assigned", "created_by", "mentioned"} | |||||
| types := []string{"all", "assigned", "created_by", "mentioned"} | |||||
| if !com.IsSliceContainsStr(types, viewType) { | if !com.IsSliceContainsStr(types, viewType) { | ||||
| viewType = "all" | viewType = "all" | ||||
| } | } | ||||
| // Must sign in to see issues about you. | |||||
| if viewType != "all" && !ctx.IsSigned { | |||||
| ctx.SetCookie("redirect_to", "/"+url.QueryEscape(setting.AppSubURL+ctx.Req.RequestURI), 0, setting.AppSubURL) | |||||
| ctx.Redirect(setting.AppSubURL + "/user/login") | |||||
| return | |||||
| } | |||||
| var ( | var ( | ||||
| assigneeID = ctx.QueryInt64("assignee") | assigneeID = ctx.QueryInt64("assignee") | ||||
| posterID int64 | posterID int64 | ||||
| mentionedID int64 | mentionedID int64 | ||||
| forceEmpty bool | forceEmpty bool | ||||
| ) | ) | ||||
| switch viewType { | |||||
| case "assigned": | |||||
| if assigneeID > 0 && ctx.User.ID != assigneeID { | |||||
| // two different assignees, must be empty | |||||
| forceEmpty = true | |||||
| } else { | |||||
| assigneeID = ctx.User.ID | |||||
| } | |||||
| case "created_by": | |||||
| posterID = ctx.User.ID | |||||
| case "mentioned": | |||||
| mentionedID = ctx.User.ID | |||||
| } | |||||
| repo := ctx.Repo.Repository | repo := ctx.Repo.Repository | ||||
| selectLabels := ctx.Query("labels") | selectLabels := ctx.Query("labels") | ||||
| @@ -183,34 +183,39 @@ func Issues(ctx *context.Context) { | |||||
| viewType string | viewType string | ||||
| sortType = ctx.Query("sort") | sortType = ctx.Query("sort") | ||||
| filterMode = models.FilterModeAll | filterMode = models.FilterModeAll | ||||
| assigneeID int64 | |||||
| posterID int64 | |||||
| ) | ) | ||||
| if ctxUser.IsOrganization() { | if ctxUser.IsOrganization() { | ||||
| viewType = "all" | viewType = "all" | ||||
| } else { | } else { | ||||
| viewType = ctx.Query("type") | viewType = ctx.Query("type") | ||||
| types := []string{"assigned", "created_by"} | |||||
| types := []string{"all", "assigned", "created_by"} | |||||
| if !com.IsSliceContainsStr(types, viewType) { | if !com.IsSliceContainsStr(types, viewType) { | ||||
| viewType = "all" | viewType = "all" | ||||
| } | } | ||||
| switch viewType { | switch viewType { | ||||
| case "all": | |||||
| filterMode = models.FilterModeAll | |||||
| case "assigned": | case "assigned": | ||||
| filterMode = models.FilterModeAssign | filterMode = models.FilterModeAssign | ||||
| assigneeID = ctxUser.ID | |||||
| case "created_by": | case "created_by": | ||||
| filterMode = models.FilterModeCreate | filterMode = models.FilterModeCreate | ||||
| posterID = ctxUser.ID | |||||
| } | } | ||||
| } | } | ||||
| page := ctx.QueryInt("page") | |||||
| if page <= 1 { | |||||
| page = 1 | |||||
| } | |||||
| repoID := ctx.QueryInt64("repo") | repoID := ctx.QueryInt64("repo") | ||||
| isShowClosed := ctx.Query("state") == "closed" | isShowClosed := ctx.Query("state") == "closed" | ||||
| // Get repositories. | // Get repositories. | ||||
| var err error | var err error | ||||
| var repos []*models.Repository | var repos []*models.Repository | ||||
| userRepoIDs := make([]int64, 0, len(repos)) | |||||
| if ctxUser.IsOrganization() { | if ctxUser.IsOrganization() { | ||||
| env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) | env, err := ctxUser.AccessibleReposEnv(ctx.User.ID) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -230,9 +235,6 @@ func Issues(ctx *context.Context) { | |||||
| repos = ctxUser.Repos | repos = ctxUser.Repos | ||||
| } | } | ||||
| allCount := 0 | |||||
| repoIDs := make([]int64, 0, len(repos)) | |||||
| showRepos := make([]*models.Repository, 0, len(repos)) | |||||
| for _, repo := range repos { | for _, repo := range repos { | ||||
| if (isPullList && repo.NumPulls == 0) || | if (isPullList && repo.NumPulls == 0) || | ||||
| (!isPullList && | (!isPullList && | ||||
| @@ -240,85 +242,129 @@ func Issues(ctx *context.Context) { | |||||
| continue | continue | ||||
| } | } | ||||
| repoIDs = append(repoIDs, repo.ID) | |||||
| userRepoIDs = append(userRepoIDs, repo.ID) | |||||
| } | |||||
| if isPullList { | |||||
| allCount += repo.NumOpenPulls | |||||
| repo.NumOpenIssues = repo.NumOpenPulls | |||||
| repo.NumClosedIssues = repo.NumClosedPulls | |||||
| } else { | |||||
| allCount += repo.NumOpenIssues | |||||
| var issues []*models.Issue | |||||
| switch filterMode { | |||||
| case models.FilterModeAll: | |||||
| // Get all issues from repositories from this user. | |||||
| issues, err = models.Issues(&models.IssuesOptions{ | |||||
| RepoIDs: userRepoIDs, | |||||
| RepoID: repoID, | |||||
| Page: page, | |||||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||||
| IsPull: util.OptionalBoolOf(isPullList), | |||||
| SortType: sortType, | |||||
| }) | |||||
| case models.FilterModeAssign: | |||||
| // Get all issues assigned to this user. | |||||
| issues, err = models.Issues(&models.IssuesOptions{ | |||||
| RepoID: repoID, | |||||
| AssigneeID: ctxUser.ID, | |||||
| Page: page, | |||||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||||
| IsPull: util.OptionalBoolOf(isPullList), | |||||
| SortType: sortType, | |||||
| }) | |||||
| case models.FilterModeCreate: | |||||
| // Get all issues created by this user. | |||||
| issues, err = models.Issues(&models.IssuesOptions{ | |||||
| RepoID: repoID, | |||||
| PosterID: ctxUser.ID, | |||||
| Page: page, | |||||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||||
| IsPull: util.OptionalBoolOf(isPullList), | |||||
| SortType: sortType, | |||||
| }) | |||||
| case models.FilterModeMention: | |||||
| // Get all issues created by this user. | |||||
| issues, err = models.Issues(&models.IssuesOptions{ | |||||
| RepoID: repoID, | |||||
| MentionedID: ctxUser.ID, | |||||
| Page: page, | |||||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||||
| IsPull: util.OptionalBoolOf(isPullList), | |||||
| SortType: sortType, | |||||
| }) | |||||
| } | |||||
| if err != nil { | |||||
| ctx.Handle(500, "Issues", err) | |||||
| return | |||||
| } | |||||
| showRepos := make([]*models.Repository, 0, len(issues)) | |||||
| showReposSet := make(map[int64]bool) | |||||
| if repoID > 0 { | |||||
| repo, err := models.GetRepositoryByID(repoID) | |||||
| if err != nil { | |||||
| ctx.Handle(500, "GetRepositoryByID", fmt.Errorf("[#%d]%v", repoID, err)) | |||||
| return | |||||
| } | } | ||||
| if filterMode != models.FilterModeAll { | |||||
| // Calculate repository issue count with filter mode. | |||||
| numOpen, numClosed := repo.IssueStats(ctxUser.ID, filterMode, isPullList) | |||||
| repo.NumOpenIssues, repo.NumClosedIssues = int(numOpen), int(numClosed) | |||||
| if err = repo.GetOwner(); err != nil { | |||||
| ctx.Handle(500, "GetOwner", fmt.Errorf("[#%d]%v", repoID, err)) | |||||
| return | |||||
| } | } | ||||
| if repo.ID == repoID || | |||||
| (isShowClosed && repo.NumClosedIssues > 0) || | |||||
| (!isShowClosed && repo.NumOpenIssues > 0) { | |||||
| showRepos = append(showRepos, repo) | |||||
| // Check if user has access to given repository. | |||||
| if !repo.IsOwnedBy(ctxUser.ID) && !repo.HasAccess(ctxUser) { | |||||
| ctx.Handle(404, "Issues", fmt.Errorf("#%d", repoID)) | |||||
| return | |||||
| } | } | ||||
| showReposSet[repoID] = true | |||||
| showRepos = append(showRepos, repo) | |||||
| } | } | ||||
| ctx.Data["Repos"] = showRepos | |||||
| if len(repoIDs) == 0 { | |||||
| repoIDs = []int64{-1} | |||||
| } | |||||
| issueStats := models.GetUserIssueStats(repoID, ctxUser.ID, repoIDs, filterMode, isPullList) | |||||
| issueStats.AllCount = int64(allCount) | |||||
| for _, issue := range issues { | |||||
| // Get Repository data. | |||||
| issue.Repo, err = models.GetRepositoryByID(issue.RepoID) | |||||
| if err != nil { | |||||
| ctx.Handle(500, "GetRepositoryByID", fmt.Errorf("[#%d]%v", issue.RepoID, err)) | |||||
| return | |||||
| } | |||||
| // Get Owner data. | |||||
| if err = issue.Repo.GetOwner(); err != nil { | |||||
| ctx.Handle(500, "GetOwner", fmt.Errorf("[#%d]%v", issue.RepoID, err)) | |||||
| return | |||||
| } | |||||
| page := ctx.QueryInt("page") | |||||
| if page <= 1 { | |||||
| page = 1 | |||||
| // Append repo to list of shown repos | |||||
| if filterMode == models.FilterModeAll { | |||||
| // Use a map to make sure we don't add the same Repository twice. | |||||
| _, ok := showReposSet[issue.RepoID] | |||||
| if !ok { | |||||
| showReposSet[issue.RepoID] = true | |||||
| // Append to list of shown Repositories. | |||||
| showRepos = append(showRepos, issue.Repo) | |||||
| } | |||||
| } | |||||
| } | } | ||||
| issueStats := models.GetUserIssueStats(repoID, ctxUser.ID, userRepoIDs, filterMode, isPullList) | |||||
| var total int | var total int | ||||
| if !isShowClosed { | if !isShowClosed { | ||||
| total = int(issueStats.OpenCount) | total = int(issueStats.OpenCount) | ||||
| } else { | } else { | ||||
| total = int(issueStats.ClosedCount) | total = int(issueStats.ClosedCount) | ||||
| } | } | ||||
| ctx.Data["Page"] = paginater.New(total, setting.UI.IssuePagingNum, page, 5) | |||||
| // Get issues. | |||||
| issues, err := models.Issues(&models.IssuesOptions{ | |||||
| AssigneeID: assigneeID, | |||||
| RepoID: repoID, | |||||
| PosterID: posterID, | |||||
| RepoIDs: repoIDs, | |||||
| Page: page, | |||||
| IsClosed: util.OptionalBoolOf(isShowClosed), | |||||
| IsPull: util.OptionalBoolOf(isPullList), | |||||
| SortType: sortType, | |||||
| }) | |||||
| if err != nil { | |||||
| ctx.Handle(500, "Issues", err) | |||||
| return | |||||
| } | |||||
| // Get posters and repository. | |||||
| for i := range issues { | |||||
| issues[i].Repo, err = models.GetRepositoryByID(issues[i].RepoID) | |||||
| if err != nil { | |||||
| ctx.Handle(500, "GetRepositoryByID", fmt.Errorf("[#%d]%v", issues[i].ID, err)) | |||||
| return | |||||
| } | |||||
| if err = issues[i].Repo.GetOwner(); err != nil { | |||||
| ctx.Handle(500, "GetOwner", fmt.Errorf("[#%d]%v", issues[i].ID, err)) | |||||
| return | |||||
| } | |||||
| } | |||||
| ctx.Data["Issues"] = issues | ctx.Data["Issues"] = issues | ||||
| ctx.Data["Repos"] = showRepos | |||||
| ctx.Data["Page"] = paginater.New(total, setting.UI.IssuePagingNum, page, 5) | |||||
| ctx.Data["IssueStats"] = issueStats | ctx.Data["IssueStats"] = issueStats | ||||
| ctx.Data["ViewType"] = viewType | ctx.Data["ViewType"] = viewType | ||||
| ctx.Data["SortType"] = sortType | ctx.Data["SortType"] = sortType | ||||
| ctx.Data["RepoID"] = repoID | ctx.Data["RepoID"] = repoID | ||||
| ctx.Data["IsShowClosed"] = isShowClosed | ctx.Data["IsShowClosed"] = isShowClosed | ||||
| if isShowClosed { | if isShowClosed { | ||||
| ctx.Data["State"] = "closed" | ctx.Data["State"] = "closed" | ||||
| } else { | } else { | ||||
| @@ -5,9 +5,9 @@ | |||||
| <div class="ui grid"> | <div class="ui grid"> | ||||
| <div class="four wide column"> | <div class="four wide column"> | ||||
| <div class="ui secondary vertical filter menu"> | <div class="ui secondary vertical filter menu"> | ||||
| <a class="{{if eq .ViewType "all"}}ui basic blue button{{end}} item" href="{{.Link}}?repo={{.RepoID}}&sort={{$.SortType}}&state={{.State}}"> | |||||
| <a class="{{if eq .ViewType "your_repositories"}}ui basic blue button{{end}} item" href="{{.Link}}?type=your_repositories&repo={{.RepoID}}&sort={{$.SortType}}&state={{.State}}"> | |||||
| {{.i18n.Tr "home.issues.in_your_repos"}} | {{.i18n.Tr "home.issues.in_your_repos"}} | ||||
| <strong class="ui right">{{.IssueStats.AllCount}}</strong> | |||||
| <strong class="ui right">{{.IssueStats.YourRepositoriesCount}}</strong> | |||||
| </a> | </a> | ||||
| {{if not .ContextUser.IsOrganization}} | {{if not .ContextUser.IsOrganization}} | ||||
| <a class="{{if eq .ViewType "assigned"}}ui basic blue button{{end}} item" href="{{.Link}}?type=assigned&repo={{.RepoID}}&sort={{$.SortType}}&state={{.State}}"> | <a class="{{if eq .ViewType "assigned"}}ui basic blue button{{end}} item" href="{{.Link}}?type=assigned&repo={{.RepoID}}&sort={{$.SortType}}&state={{.State}}"> | ||||
| @@ -22,7 +22,7 @@ | |||||
| <div class="ui divider"></div> | <div class="ui divider"></div> | ||||
| {{range .Repos}} | {{range .Repos}} | ||||
| <a class="{{if eq $.RepoID .ID}}ui basic blue button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}{{if not (eq $.RepoID .ID)}}&repo={{.ID}}{{end}}&sort={{$.SortType}}&state={{$.State}}"> | <a class="{{if eq $.RepoID .ID}}ui basic blue button{{end}} repo name item" href="{{$.Link}}?type={{$.ViewType}}{{if not (eq $.RepoID .ID)}}&repo={{.ID}}{{end}}&sort={{$.SortType}}&state={{$.State}}"> | ||||
| <span class="text truncate">{{$.ContextUser.Name}}/{{.Name}}</span> | |||||
| <span class="text truncate">{{.FullName}}</span> | |||||
| <div class="floating ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{if $.IsShowClosed}}{{.NumClosedIssues}}{{else}}{{.NumOpenIssues}}{{end}}</div> | <div class="floating ui {{if $.IsShowClosed}}red{{else}}green{{end}} label">{{if $.IsShowClosed}}{{.NumClosedIssues}}{{else}}{{.NumOpenIssues}}{{end}}</div> | ||||
| </a> | </a> | ||||
| {{end}} | {{end}} | ||||
| @@ -61,7 +61,7 @@ | |||||
| {{range .Issues}} | {{range .Issues}} | ||||
| {{ $timeStr:= TimeSince .Created $.Lang }} | {{ $timeStr:= TimeSince .Created $.Lang }} | ||||
| <li class="item"> | <li class="item"> | ||||
| <div class="ui label">{{if not $.RepoID}}{{.Repo.Name}}{{end}}#{{.Index}}</div> | |||||
| <div class="ui label">{{if not $.RepoID}}{{.Repo.FullName}}{{end}}#{{.Index}}</div> | |||||
| <a class="title has-emoji" href="{{AppSubUrl}}/{{.Repo.Owner.Name}}/{{.Repo.Name}}/issues/{{.Index}}">{{.Title}}</a> | <a class="title has-emoji" href="{{AppSubUrl}}/{{.Repo.Owner.Name}}/{{.Repo.Name}}/issues/{{.Index}}">{{.Title}}</a> | ||||
| {{range .Labels}} | {{range .Labels}} | ||||