mirror of https://github.com/go-gitea/gitea
Git with a cup of tea, painless self-hosted git service
Mirror for internal git.with.parts use
https://git.with.parts
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
883 lines
24 KiB
883 lines
24 KiB
2 weeks ago
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||
|
// SPDX-License-Identifier: MIT
|
||
|
|
||
|
package repo
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"fmt"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
|
||
|
"code.gitea.io/gitea/models/db"
|
||
|
git_model "code.gitea.io/gitea/models/git"
|
||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||
|
"code.gitea.io/gitea/models/organization"
|
||
|
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"
|
||
|
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||
|
"code.gitea.io/gitea/services/context"
|
||
|
"code.gitea.io/gitea/services/convert"
|
||
|
issue_service "code.gitea.io/gitea/services/issue"
|
||
|
pull_service "code.gitea.io/gitea/services/pull"
|
||
|
)
|
||
|
|
||
|
func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) {
|
||
|
ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts))
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("SearchIssues: %w", err)
|
||
|
}
|
||
|
return ids, nil
|
||
|
}
|
||
|
|
||
|
func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) {
|
||
|
ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo)
|
||
|
}
|
||
|
|
||
|
// SearchIssues searches for issues across the repositories that the user has access to
|
||
|
func SearchIssues(ctx *context.Context) {
|
||
|
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusUnprocessableEntity, err.Error())
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var isClosed optional.Option[bool]
|
||
|
switch ctx.FormString("state") {
|
||
|
case "closed":
|
||
|
isClosed = optional.Some(true)
|
||
|
case "all":
|
||
|
isClosed = optional.None[bool]()
|
||
|
default:
|
||
|
isClosed = optional.Some(false)
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
repoIDs []int64
|
||
|
allPublic bool
|
||
|
)
|
||
|
{
|
||
|
// find repos user can access (for issue search)
|
||
|
opts := &repo_model.SearchRepoOptions{
|
||
|
Private: false,
|
||
|
AllPublic: true,
|
||
|
TopicOnly: false,
|
||
|
Collaborate: optional.None[bool](),
|
||
|
// This needs to be a column that is not nil in fixtures or
|
||
|
// MySQL will return different results when sorting by null in some cases
|
||
|
OrderBy: db.SearchOrderByAlphabetically,
|
||
|
Actor: ctx.Doer,
|
||
|
}
|
||
|
if ctx.IsSigned {
|
||
|
opts.Private = true
|
||
|
opts.AllLimited = true
|
||
|
}
|
||
|
if ctx.FormString("owner") != "" {
|
||
|
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
|
||
|
if err != nil {
|
||
|
if user_model.IsErrUserNotExist(err) {
|
||
|
ctx.Error(http.StatusBadRequest, "Owner not found", err.Error())
|
||
|
} else {
|
||
|
ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error())
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
opts.OwnerID = owner.ID
|
||
|
opts.AllLimited = false
|
||
|
opts.AllPublic = false
|
||
|
opts.Collaborate = optional.Some(false)
|
||
|
}
|
||
|
if ctx.FormString("team") != "" {
|
||
|
if ctx.FormString("owner") == "" {
|
||
|
ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team")
|
||
|
return
|
||
|
}
|
||
|
team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team"))
|
||
|
if err != nil {
|
||
|
if organization.IsErrTeamNotExist(err) {
|
||
|
ctx.Error(http.StatusBadRequest, "Team not found", err.Error())
|
||
|
} else {
|
||
|
ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error())
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
opts.TeamID = team.ID
|
||
|
}
|
||
|
|
||
|
if opts.AllPublic {
|
||
|
allPublic = true
|
||
|
opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer
|
||
|
}
|
||
|
repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error())
|
||
|
return
|
||
|
}
|
||
|
if len(repoIDs) == 0 {
|
||
|
// no repos found, don't let the indexer return all repos
|
||
|
repoIDs = []int64{0}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
keyword := ctx.FormTrim("q")
|
||
|
if strings.IndexByte(keyword, 0) >= 0 {
|
||
|
keyword = ""
|
||
|
}
|
||
|
|
||
|
isPull := optional.None[bool]()
|
||
|
switch ctx.FormString("type") {
|
||
|
case "pulls":
|
||
|
isPull = optional.Some(true)
|
||
|
case "issues":
|
||
|
isPull = optional.Some(false)
|
||
|
}
|
||
|
|
||
|
var includedAnyLabels []int64
|
||
|
{
|
||
|
labels := ctx.FormTrim("labels")
|
||
|
var includedLabelNames []string
|
||
|
if len(labels) > 0 {
|
||
|
includedLabelNames = strings.Split(labels, ",")
|
||
|
}
|
||
|
includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err.Error())
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var includedMilestones []int64
|
||
|
{
|
||
|
milestones := ctx.FormTrim("milestones")
|
||
|
var includedMilestoneNames []string
|
||
|
if len(milestones) > 0 {
|
||
|
includedMilestoneNames = strings.Split(milestones, ",")
|
||
|
}
|
||
|
includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err.Error())
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
projectID := optional.None[int64]()
|
||
|
if v := ctx.FormInt64("project"); v > 0 {
|
||
|
projectID = optional.Some(v)
|
||
|
}
|
||
|
|
||
|
// this api is also used in UI,
|
||
|
// so the default limit is set to fit UI needs
|
||
|
limit := ctx.FormInt("limit")
|
||
|
if limit == 0 {
|
||
|
limit = setting.UI.IssuePagingNum
|
||
|
} else if limit > setting.API.MaxResponseItems {
|
||
|
limit = setting.API.MaxResponseItems
|
||
|
}
|
||
|
|
||
|
searchOpt := &issue_indexer.SearchOptions{
|
||
|
Paginator: &db.ListOptions{
|
||
|
Page: ctx.FormInt("page"),
|
||
|
PageSize: limit,
|
||
|
},
|
||
|
Keyword: keyword,
|
||
|
RepoIDs: repoIDs,
|
||
|
AllPublic: allPublic,
|
||
|
IsPull: isPull,
|
||
|
IsClosed: isClosed,
|
||
|
IncludedAnyLabelIDs: includedAnyLabels,
|
||
|
MilestoneIDs: includedMilestones,
|
||
|
ProjectID: projectID,
|
||
|
SortBy: issue_indexer.SortByCreatedDesc,
|
||
|
}
|
||
|
|
||
|
if since != 0 {
|
||
|
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
||
|
}
|
||
|
if before != 0 {
|
||
|
searchOpt.UpdatedBeforeUnix = optional.Some(before)
|
||
|
}
|
||
|
|
||
|
if ctx.IsSigned {
|
||
|
ctxUserID := ctx.Doer.ID
|
||
|
if ctx.FormBool("created") {
|
||
|
searchOpt.PosterID = optional.Some(ctxUserID)
|
||
|
}
|
||
|
if ctx.FormBool("assigned") {
|
||
|
searchOpt.AssigneeID = optional.Some(ctxUserID)
|
||
|
}
|
||
|
if ctx.FormBool("mentioned") {
|
||
|
searchOpt.MentionID = optional.Some(ctxUserID)
|
||
|
}
|
||
|
if ctx.FormBool("review_requested") {
|
||
|
searchOpt.ReviewRequestedID = optional.Some(ctxUserID)
|
||
|
}
|
||
|
if ctx.FormBool("reviewed") {
|
||
|
searchOpt.ReviewedID = optional.Some(ctxUserID)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// FIXME: It's unsupported to sort by priority repo when searching by indexer,
|
||
|
// it's indeed an regression, but I think it is worth to support filtering by indexer first.
|
||
|
_ = ctx.FormInt64("priority_repo_id")
|
||
|
|
||
|
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error())
|
||
|
return
|
||
|
}
|
||
|
issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error())
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx.SetTotalCountHeader(total)
|
||
|
ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues))
|
||
|
}
|
||
|
|
||
|
func getUserIDForFilter(ctx *context.Context, queryName string) int64 {
|
||
|
userName := ctx.FormString(queryName)
|
||
|
if len(userName) == 0 {
|
||
|
return 0
|
||
|
}
|
||
|
|
||
|
user, err := user_model.GetUserByName(ctx, userName)
|
||
|
if user_model.IsErrUserNotExist(err) {
|
||
|
ctx.NotFound("", err)
|
||
|
return 0
|
||
|
}
|
||
|
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, err.Error())
|
||
|
return 0
|
||
|
}
|
||
|
|
||
|
return user.ID
|
||
|
}
|
||
|
|
||
|
// ListIssues list the issues of a repository
|
||
|
func ListIssues(ctx *context.Context) {
|
||
|
before, since, err := context.GetQueryBeforeSince(ctx.Base)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusUnprocessableEntity, err.Error())
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var isClosed optional.Option[bool]
|
||
|
switch ctx.FormString("state") {
|
||
|
case "closed":
|
||
|
isClosed = optional.Some(true)
|
||
|
case "all":
|
||
|
isClosed = optional.None[bool]()
|
||
|
default:
|
||
|
isClosed = optional.Some(false)
|
||
|
}
|
||
|
|
||
|
keyword := ctx.FormTrim("q")
|
||
|
if strings.IndexByte(keyword, 0) >= 0 {
|
||
|
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
|
||
|
mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i])
|
||
|
if err == nil {
|
||
|
mileIDs = append(mileIDs, mile.ID)
|
||
|
continue
|
||
|
}
|
||
|
if !issues_model.IsErrMilestoneNotExist(err) {
|
||
|
ctx.Error(http.StatusInternalServerError, err.Error())
|
||
|
return
|
||
|
}
|
||
|
id, err := strconv.ParseInt(part[i], 10, 64)
|
||
|
if err != nil {
|
||
|
continue
|
||
|
}
|
||
|
mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id)
|
||
|
if err == nil {
|
||
|
mileIDs = append(mileIDs, mile.ID)
|
||
|
continue
|
||
|
}
|
||
|
if issues_model.IsErrMilestoneNotExist(err) {
|
||
|
continue
|
||
|
}
|
||
|
ctx.Error(http.StatusInternalServerError, err.Error())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
projectID := optional.None[int64]()
|
||
|
if v := ctx.FormInt64("project"); v > 0 {
|
||
|
projectID = optional.Some(v)
|
||
|
}
|
||
|
|
||
|
isPull := optional.None[bool]()
|
||
|
switch ctx.FormString("type") {
|
||
|
case "pulls":
|
||
|
isPull = optional.Some(true)
|
||
|
case "issues":
|
||
|
isPull = optional.Some(false)
|
||
|
}
|
||
|
|
||
|
// FIXME: we should be more efficient here
|
||
|
createdByID := getUserIDForFilter(ctx, "created_by")
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
assignedByID := getUserIDForFilter(ctx, "assigned_by")
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
mentionedByID := getUserIDForFilter(ctx, "mentioned_by")
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
searchOpt := &issue_indexer.SearchOptions{
|
||
|
Paginator: &db.ListOptions{
|
||
|
Page: ctx.FormInt("page"),
|
||
|
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||
|
},
|
||
|
Keyword: keyword,
|
||
|
RepoIDs: []int64{ctx.Repo.Repository.ID},
|
||
|
IsPull: isPull,
|
||
|
IsClosed: isClosed,
|
||
|
ProjectID: projectID,
|
||
|
SortBy: issue_indexer.SortByCreatedDesc,
|
||
|
}
|
||
|
if since != 0 {
|
||
|
searchOpt.UpdatedAfterUnix = optional.Some(since)
|
||
|
}
|
||
|
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)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID {
|
||
|
searchOpt.MilestoneIDs = []int64{0}
|
||
|
} else {
|
||
|
searchOpt.MilestoneIDs = mileIDs
|
||
|
}
|
||
|
|
||
|
if createdByID > 0 {
|
||
|
searchOpt.PosterID = optional.Some(createdByID)
|
||
|
}
|
||
|
if assignedByID > 0 {
|
||
|
searchOpt.AssigneeID = optional.Some(assignedByID)
|
||
|
}
|
||
|
if mentionedByID > 0 {
|
||
|
searchOpt.MentionID = optional.Some(mentionedByID)
|
||
|
}
|
||
|
|
||
|
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error())
|
||
|
return
|
||
|
}
|
||
|
issues, err := issues_model.GetIssuesByIDs(ctx, ids, true)
|
||
|
if err != nil {
|
||
|
ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error())
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx.SetTotalCountHeader(total)
|
||
|
ctx.JSON(http.StatusOK, convert.ToIssueList(ctx, ctx.Doer, issues))
|
||
|
}
|
||
|
|
||
|
func BatchDeleteIssues(ctx *context.Context) {
|
||
|
issues := getActionIssues(ctx)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
for _, issue := range issues {
|
||
|
if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil {
|
||
|
ctx.ServerError("DeleteIssue", err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
ctx.JSONOK()
|
||
|
}
|
||
|
|
||
|
// UpdateIssueStatus change issue's status
|
||
|
func UpdateIssueStatus(ctx *context.Context) {
|
||
|
issues := getActionIssues(ctx)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var isClosed bool
|
||
|
switch action := ctx.FormString("action"); action {
|
||
|
case "open":
|
||
|
isClosed = false
|
||
|
case "close":
|
||
|
isClosed = true
|
||
|
default:
|
||
|
log.Warn("Unrecognized action: %s", action)
|
||
|
}
|
||
|
|
||
|
if _, err := issues.LoadRepositories(ctx); err != nil {
|
||
|
ctx.ServerError("LoadRepositories", err)
|
||
|
return
|
||
|
}
|
||
|
if err := issues.LoadPullRequests(ctx); err != nil {
|
||
|
ctx.ServerError("LoadPullRequests", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
for _, issue := range issues {
|
||
|
if issue.IsPull && issue.PullRequest.HasMerged {
|
||
|
continue
|
||
|
}
|
||
|
if issue.IsClosed != isClosed {
|
||
|
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil {
|
||
|
if issues_model.IsErrDependenciesLeft(err) {
|
||
|
ctx.JSON(http.StatusPreconditionFailed, map[string]any{
|
||
|
"error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index),
|
||
|
})
|
||
|
return
|
||
|
}
|
||
|
ctx.ServerError("ChangeStatus", err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
ctx.JSONOK()
|
||
|
}
|
||
|
|
||
|
func renderMilestones(ctx *context.Context) {
|
||
|
// Get milestones
|
||
|
milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
|
||
|
RepoID: ctx.Repo.Repository.ID,
|
||
|
})
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetAllRepoMilestones", 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
|
||
|
}
|
||
|
|
||
|
func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) {
|
||
|
var err error
|
||
|
viewType := ctx.FormString("type")
|
||
|
sortType := ctx.FormString("sort")
|
||
|
types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"}
|
||
|
if !util.SliceContainsString(types, viewType, true) {
|
||
|
viewType = "all"
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
assigneeID = ctx.FormInt64("assignee")
|
||
|
posterID = ctx.FormInt64("poster")
|
||
|
mentionedID int64
|
||
|
reviewRequestedID int64
|
||
|
reviewedID int64
|
||
|
)
|
||
|
|
||
|
if ctx.IsSigned {
|
||
|
switch viewType {
|
||
|
case "created_by":
|
||
|
posterID = ctx.Doer.ID
|
||
|
case "mentioned":
|
||
|
mentionedID = ctx.Doer.ID
|
||
|
case "assigned":
|
||
|
assigneeID = ctx.Doer.ID
|
||
|
case "review_requested":
|
||
|
reviewRequestedID = ctx.Doer.ID
|
||
|
case "reviewed_by":
|
||
|
reviewedID = ctx.Doer.ID
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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 == "" {
|
||
|
ctx.Data["AllLabels"] = true
|
||
|
} else if selectLabels == "0" {
|
||
|
ctx.Data["NoLabel"] = true
|
||
|
}
|
||
|
if len(selectLabels) > 0 {
|
||
|
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 = ""
|
||
|
}
|
||
|
|
||
|
var mileIDs []int64
|
||
|
if milestoneID > 0 || milestoneID == db.NoConditionID { // -1 to get those issues which have no any milestone assigned
|
||
|
mileIDs = []int64{milestoneID}
|
||
|
}
|
||
|
|
||
|
var issueStats *issues_model.IssueStats
|
||
|
statsOpts := &issues_model.IssuesOptions{
|
||
|
RepoIDs: []int64{repo.ID},
|
||
|
LabelIDs: labelIDs,
|
||
|
MilestoneIDs: mileIDs,
|
||
|
ProjectID: projectID,
|
||
|
AssigneeID: assigneeID,
|
||
|
MentionedID: mentionedID,
|
||
|
PosterID: posterID,
|
||
|
ReviewRequestedID: reviewRequestedID,
|
||
|
ReviewedID: reviewedID,
|
||
|
IsPull: isPullOption,
|
||
|
IssueIDs: nil,
|
||
|
}
|
||
|
if keyword != "" {
|
||
|
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
|
||
|
if err != nil {
|
||
|
if issue_indexer.IsAvailable(ctx) {
|
||
|
ctx.ServerError("issueIDsFromSearch", err)
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["IssueIndexerUnavailable"] = true
|
||
|
return
|
||
|
}
|
||
|
statsOpts.IssueIDs = allIssueIDs
|
||
|
}
|
||
|
if keyword != "" && len(statsOpts.IssueIDs) == 0 {
|
||
|
// So it did search with the keyword, but no issue found.
|
||
|
// Just set issueStats to empty.
|
||
|
issueStats = &issues_model.IssueStats{}
|
||
|
} else {
|
||
|
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
|
||
|
// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
|
||
|
issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetIssueStats", err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var isShowClosed optional.Option[bool]
|
||
|
switch ctx.FormString("state") {
|
||
|
case "closed":
|
||
|
isShowClosed = optional.Some(true)
|
||
|
case "all":
|
||
|
isShowClosed = optional.None[bool]()
|
||
|
default:
|
||
|
isShowClosed = optional.Some(false)
|
||
|
}
|
||
|
// if there are closed issues and no open issues, default to showing all issues
|
||
|
if len(ctx.FormString("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 {
|
||
|
isShowClosed = optional.None[bool]()
|
||
|
}
|
||
|
|
||
|
if repo.IsTimetrackerEnabled(ctx) {
|
||
|
totalTrackedTime, err := issues_model.GetIssueTotalTrackedTime(ctx, statsOpts, isShowClosed)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetIssueTotalTrackedTime", err)
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["TotalTrackedTime"] = totalTrackedTime
|
||
|
}
|
||
|
|
||
|
archived := ctx.FormBool("archived")
|
||
|
|
||
|
page := ctx.FormInt("page")
|
||
|
if page <= 1 {
|
||
|
page = 1
|
||
|
}
|
||
|
|
||
|
var total int
|
||
|
switch {
|
||
|
case isShowClosed.Value():
|
||
|
total = int(issueStats.ClosedCount)
|
||
|
case !isShowClosed.Has():
|
||
|
total = int(issueStats.OpenCount + issueStats.ClosedCount)
|
||
|
default:
|
||
|
total = int(issueStats.OpenCount)
|
||
|
}
|
||
|
pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5)
|
||
|
|
||
|
var issues issues_model.IssueList
|
||
|
{
|
||
|
ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{
|
||
|
Paginator: &db.ListOptions{
|
||
|
Page: pager.Paginater.Current(),
|
||
|
PageSize: setting.UI.IssuePagingNum,
|
||
|
},
|
||
|
RepoIDs: []int64{repo.ID},
|
||
|
AssigneeID: assigneeID,
|
||
|
PosterID: posterID,
|
||
|
MentionedID: mentionedID,
|
||
|
ReviewRequestedID: reviewRequestedID,
|
||
|
ReviewedID: reviewedID,
|
||
|
MilestoneIDs: mileIDs,
|
||
|
ProjectID: projectID,
|
||
|
IsClosed: isShowClosed,
|
||
|
IsPull: isPullOption,
|
||
|
LabelIDs: labelIDs,
|
||
|
SortType: sortType,
|
||
|
})
|
||
|
if err != nil {
|
||
|
if issue_indexer.IsAvailable(ctx) {
|
||
|
ctx.ServerError("issueIDsFromSearch", err)
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["IssueIndexerUnavailable"] = true
|
||
|
return
|
||
|
}
|
||
|
issues, err = issues_model.GetIssuesByIDs(ctx, ids, true)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetIssuesByIDs", err)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
approvalCounts, err := issues.GetApprovalCounts(ctx)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("ApprovalCounts", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if ctx.IsSigned {
|
||
|
if err := issues.LoadIsRead(ctx, ctx.Doer.ID); err != nil {
|
||
|
ctx.ServerError("LoadIsRead", err)
|
||
|
return
|
||
|
}
|
||
|
} else {
|
||
|
for i := range issues {
|
||
|
issues[i].IsRead = true
|
||
|
}
|
||
|
}
|
||
|
|
||
|
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetIssuesAllCommitStatus", err)
|
||
|
return
|
||
|
}
|
||
|
if !ctx.Repo.CanRead(unit.TypeActions) {
|
||
|
for key := range commitStatuses {
|
||
|
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if err := issues.LoadAttributes(ctx); err != nil {
|
||
|
ctx.ServerError("issues.LoadAttributes", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx.Data["Issues"] = issues
|
||
|
ctx.Data["CommitLastStatus"] = lastStatus
|
||
|
ctx.Data["CommitStatuses"] = commitStatuses
|
||
|
|
||
|
// Get assignees.
|
||
|
assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo)
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetRepoAssignees", err)
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||
|
|
||
|
handleTeamMentions(ctx)
|
||
|
if ctx.Written() {
|
||
|
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 {
|
||
|
counts, ok := approvalCounts[issueID]
|
||
|
if !ok || len(counts) == 0 {
|
||
|
return 0
|
||
|
}
|
||
|
reviewTyp := issues_model.ReviewTypeApprove
|
||
|
if typ == "reject" {
|
||
|
reviewTyp = issues_model.ReviewTypeReject
|
||
|
} else if typ == "waiting" {
|
||
|
reviewTyp = issues_model.ReviewTypeRequest
|
||
|
}
|
||
|
for _, count := range counts {
|
||
|
if count.Type == reviewTyp {
|
||
|
return count.Count
|
||
|
}
|
||
|
}
|
||
|
return 0
|
||
|
}
|
||
|
|
||
|
retrieveProjectsForIssueList(ctx, repo)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.Value())
|
||
|
if err != nil {
|
||
|
ctx.ServerError("GetPinnedIssues", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx.Data["PinnedIssues"] = pinned
|
||
|
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
|
||
|
ctx.Data["IssueStats"] = issueStats
|
||
|
ctx.Data["OpenCount"] = issueStats.OpenCount
|
||
|
ctx.Data["ClosedCount"] = issueStats.ClosedCount
|
||
|
linkStr := "%s?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%d&archived=%t"
|
||
|
ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr, ctx.Link,
|
||
|
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels),
|
||
|
milestoneID, projectID, assigneeID, posterID, archived)
|
||
|
ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Link,
|
||
|
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels),
|
||
|
milestoneID, projectID, assigneeID, posterID, archived)
|
||
|
ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Link,
|
||
|
url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels),
|
||
|
milestoneID, projectID, assigneeID, posterID, archived)
|
||
|
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["PosterID"] = posterID
|
||
|
ctx.Data["Keyword"] = keyword
|
||
|
ctx.Data["IsShowClosed"] = isShowClosed
|
||
|
switch {
|
||
|
case isShowClosed.Value():
|
||
|
ctx.Data["State"] = "closed"
|
||
|
case !isShowClosed.Has():
|
||
|
ctx.Data["State"] = "all"
|
||
|
default:
|
||
|
ctx.Data["State"] = "open"
|
||
|
}
|
||
|
ctx.Data["ShowArchivedLabels"] = archived
|
||
|
|
||
|
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", fmt.Sprint(posterID))
|
||
|
pager.AddParamString("archived", fmt.Sprint(archived))
|
||
|
|
||
|
ctx.Data["Page"] = pager
|
||
|
}
|
||
|
|
||
|
// Issues render issues page
|
||
|
func Issues(ctx *context.Context) {
|
||
|
isPullList := ctx.PathParam(":type") == "pulls"
|
||
|
if isPullList {
|
||
|
MustAllowPulls(ctx)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["Title"] = ctx.Tr("repo.pulls")
|
||
|
ctx.Data["PageIsPullList"] = true
|
||
|
} else {
|
||
|
MustEnableIssues(ctx)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
ctx.Data["Title"] = ctx.Tr("repo.issues")
|
||
|
ctx.Data["PageIsIssueList"] = true
|
||
|
ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||
|
}
|
||
|
|
||
|
issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList))
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
renderMilestones(ctx)
|
||
|
if ctx.Written() {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList)
|
||
|
|
||
|
ctx.HTML(http.StatusOK, tplIssues)
|
||
|
}
|