code.gitea.io/gitea@v1.22.3/models/issues/issue_search.go (about)

     1  // Copyright 2023 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package issues
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"strings"
    10  
    11  	"code.gitea.io/gitea/models/db"
    12  	"code.gitea.io/gitea/models/organization"
    13  	repo_model "code.gitea.io/gitea/models/repo"
    14  	"code.gitea.io/gitea/models/unit"
    15  	user_model "code.gitea.io/gitea/models/user"
    16  	"code.gitea.io/gitea/modules/optional"
    17  
    18  	"xorm.io/builder"
    19  	"xorm.io/xorm"
    20  )
    21  
    22  // IssuesOptions represents options of an issue.
    23  type IssuesOptions struct { //nolint
    24  	Paginator          *db.ListOptions
    25  	RepoIDs            []int64 // overwrites RepoCond if the length is not 0
    26  	AllPublic          bool    // include also all public repositories
    27  	RepoCond           builder.Cond
    28  	AssigneeID         int64
    29  	PosterID           int64
    30  	MentionedID        int64
    31  	ReviewRequestedID  int64
    32  	ReviewedID         int64
    33  	SubscriberID       int64
    34  	MilestoneIDs       []int64
    35  	ProjectID          int64
    36  	ProjectBoardID     int64
    37  	IsClosed           optional.Option[bool]
    38  	IsPull             optional.Option[bool]
    39  	LabelIDs           []int64
    40  	IncludedLabelNames []string
    41  	ExcludedLabelNames []string
    42  	IncludeMilestones  []string
    43  	SortType           string
    44  	IssueIDs           []int64
    45  	UpdatedAfterUnix   int64
    46  	UpdatedBeforeUnix  int64
    47  	// prioritize issues from this repo
    48  	PriorityRepoID int64
    49  	IsArchived     optional.Option[bool]
    50  	Org            *organization.Organization // issues permission scope
    51  	Team           *organization.Team         // issues permission scope
    52  	User           *user_model.User           // issues permission scope
    53  }
    54  
    55  // applySorts sort an issues-related session based on the provided
    56  // sortType string
    57  func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) {
    58  	switch sortType {
    59  	case "oldest":
    60  		sess.Asc("issue.created_unix").Asc("issue.id")
    61  	case "recentupdate":
    62  		sess.Desc("issue.updated_unix").Desc("issue.created_unix").Desc("issue.id")
    63  	case "leastupdate":
    64  		sess.Asc("issue.updated_unix").Asc("issue.created_unix").Asc("issue.id")
    65  	case "mostcomment":
    66  		sess.Desc("issue.num_comments").Desc("issue.created_unix").Desc("issue.id")
    67  	case "leastcomment":
    68  		sess.Asc("issue.num_comments").Desc("issue.created_unix").Desc("issue.id")
    69  	case "priority":
    70  		sess.Desc("issue.priority").Desc("issue.created_unix").Desc("issue.id")
    71  	case "nearduedate":
    72  		// 253370764800 is 01/01/9999 @ 12:00am (UTC)
    73  		sess.Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
    74  			OrderBy("CASE " +
    75  				"WHEN issue.deadline_unix = 0 AND (milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL) THEN 253370764800 " +
    76  				"WHEN milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL THEN issue.deadline_unix " +
    77  				"WHEN milestone.deadline_unix < issue.deadline_unix OR issue.deadline_unix = 0 THEN milestone.deadline_unix " +
    78  				"ELSE issue.deadline_unix END ASC").
    79  			Desc("issue.created_unix").
    80  			Desc("issue.id")
    81  	case "farduedate":
    82  		sess.Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
    83  			OrderBy("CASE " +
    84  				"WHEN milestone.deadline_unix IS NULL THEN issue.deadline_unix " +
    85  				"WHEN milestone.deadline_unix < issue.deadline_unix OR issue.deadline_unix = 0 THEN milestone.deadline_unix " +
    86  				"ELSE issue.deadline_unix END DESC").
    87  			Desc("issue.created_unix").
    88  			Desc("issue.id")
    89  	case "priorityrepo":
    90  		sess.OrderBy("CASE "+
    91  			"WHEN issue.repo_id = ? THEN 1 "+
    92  			"ELSE 2 END ASC", priorityRepoID).
    93  			Desc("issue.created_unix").
    94  			Desc("issue.id")
    95  	case "project-column-sorting":
    96  		sess.Asc("project_issue.sorting").Desc("issue.created_unix").Desc("issue.id")
    97  	default:
    98  		sess.Desc("issue.created_unix").Desc("issue.id")
    99  	}
   100  }
   101  
   102  func applyLimit(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   103  	if opts.Paginator == nil || opts.Paginator.IsListAll() {
   104  		return sess
   105  	}
   106  
   107  	start := 0
   108  	if opts.Paginator.Page > 1 {
   109  		start = (opts.Paginator.Page - 1) * opts.Paginator.PageSize
   110  	}
   111  	sess.Limit(opts.Paginator.PageSize, start)
   112  
   113  	return sess
   114  }
   115  
   116  func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   117  	if len(opts.LabelIDs) > 0 {
   118  		if opts.LabelIDs[0] == 0 {
   119  			sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)")
   120  		} else {
   121  			for i, labelID := range opts.LabelIDs {
   122  				if labelID > 0 {
   123  					sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
   124  						fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
   125  				} else if labelID < 0 { // 0 is not supported here, so just ignore it
   126  					sess.Where("issue.id not in (select issue_id from issue_label where label_id = ?)", -labelID)
   127  				}
   128  			}
   129  		}
   130  	}
   131  
   132  	if len(opts.IncludedLabelNames) > 0 {
   133  		sess.In("issue.id", BuildLabelNamesIssueIDsCondition(opts.IncludedLabelNames))
   134  	}
   135  
   136  	if len(opts.ExcludedLabelNames) > 0 {
   137  		sess.And(builder.NotIn("issue.id", BuildLabelNamesIssueIDsCondition(opts.ExcludedLabelNames)))
   138  	}
   139  
   140  	return sess
   141  }
   142  
   143  func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   144  	if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID {
   145  		sess.And("issue.milestone_id = 0")
   146  	} else if len(opts.MilestoneIDs) > 0 {
   147  		sess.In("issue.milestone_id", opts.MilestoneIDs)
   148  	}
   149  
   150  	if len(opts.IncludeMilestones) > 0 {
   151  		sess.In("issue.milestone_id",
   152  			builder.Select("id").
   153  				From("milestone").
   154  				Where(builder.In("name", opts.IncludeMilestones)))
   155  	}
   156  
   157  	return sess
   158  }
   159  
   160  func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   161  	if opts.ProjectID > 0 { // specific project
   162  		sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
   163  			And("project_issue.project_id=?", opts.ProjectID)
   164  	} else if opts.ProjectID == db.NoConditionID { // show those that are in no project
   165  		sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0})))
   166  	}
   167  	// opts.ProjectID == 0 means all projects,
   168  	// do not need to apply any condition
   169  	return sess
   170  }
   171  
   172  func applyProjectBoardCondition(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   173  	// opts.ProjectBoardID == 0 means all project boards,
   174  	// do not need to apply any condition
   175  	if opts.ProjectBoardID > 0 {
   176  		sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID}))
   177  	} else if opts.ProjectBoardID == db.NoConditionID {
   178  		sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}))
   179  	}
   180  	return sess
   181  }
   182  
   183  func applyRepoConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   184  	if len(opts.RepoIDs) == 1 {
   185  		opts.RepoCond = builder.Eq{"issue.repo_id": opts.RepoIDs[0]}
   186  	} else if len(opts.RepoIDs) > 1 {
   187  		opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
   188  	}
   189  	if opts.AllPublic {
   190  		if opts.RepoCond == nil {
   191  			opts.RepoCond = builder.NewCond()
   192  		}
   193  		opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false})))
   194  	}
   195  	if opts.RepoCond != nil {
   196  		sess.And(opts.RepoCond)
   197  	}
   198  	return sess
   199  }
   200  
   201  func applyConditions(sess *xorm.Session, opts *IssuesOptions) *xorm.Session {
   202  	if len(opts.IssueIDs) > 0 {
   203  		sess.In("issue.id", opts.IssueIDs)
   204  	}
   205  
   206  	applyRepoConditions(sess, opts)
   207  
   208  	if opts.IsClosed.Has() {
   209  		sess.And("issue.is_closed=?", opts.IsClosed.Value())
   210  	}
   211  
   212  	if opts.AssigneeID > 0 {
   213  		applyAssigneeCondition(sess, opts.AssigneeID)
   214  	} else if opts.AssigneeID == db.NoConditionID {
   215  		sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_assignees)")
   216  	}
   217  
   218  	if opts.PosterID > 0 {
   219  		applyPosterCondition(sess, opts.PosterID)
   220  	}
   221  
   222  	if opts.MentionedID > 0 {
   223  		applyMentionedCondition(sess, opts.MentionedID)
   224  	}
   225  
   226  	if opts.ReviewRequestedID > 0 {
   227  		applyReviewRequestedCondition(sess, opts.ReviewRequestedID)
   228  	}
   229  
   230  	if opts.ReviewedID > 0 {
   231  		applyReviewedCondition(sess, opts.ReviewedID)
   232  	}
   233  
   234  	if opts.SubscriberID > 0 {
   235  		applySubscribedCondition(sess, opts.SubscriberID)
   236  	}
   237  
   238  	applyMilestoneCondition(sess, opts)
   239  
   240  	if opts.UpdatedAfterUnix != 0 {
   241  		sess.And(builder.Gte{"issue.updated_unix": opts.UpdatedAfterUnix})
   242  	}
   243  	if opts.UpdatedBeforeUnix != 0 {
   244  		sess.And(builder.Lte{"issue.updated_unix": opts.UpdatedBeforeUnix})
   245  	}
   246  
   247  	applyProjectCondition(sess, opts)
   248  
   249  	applyProjectBoardCondition(sess, opts)
   250  
   251  	if opts.IsPull.Has() {
   252  		sess.And("issue.is_pull=?", opts.IsPull.Value())
   253  	}
   254  
   255  	if opts.IsArchived.Has() {
   256  		sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()})
   257  	}
   258  
   259  	applyLabelsCondition(sess, opts)
   260  
   261  	if opts.User != nil {
   262  		sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.Value()))
   263  	}
   264  
   265  	return sess
   266  }
   267  
   268  // teamUnitsRepoCond returns query condition for those repo id in the special org team with special units access
   269  func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond {
   270  	return builder.In(id,
   271  		builder.Select("repo_id").From("team_repo").Where(
   272  			builder.Eq{
   273  				"team_id": teamID,
   274  			}.And(
   275  				builder.Or(
   276  					// Check if the user is member of the team.
   277  					builder.In(
   278  						"team_id", builder.Select("team_id").From("team_user").Where(
   279  							builder.Eq{
   280  								"uid": userID,
   281  							},
   282  						),
   283  					),
   284  					// Check if the user is in the owner team of the organisation.
   285  					builder.Exists(builder.Select("team_id").From("team_user").
   286  						Where(builder.Eq{
   287  							"org_id": orgID,
   288  							"team_id": builder.Select("id").From("team").Where(
   289  								builder.Eq{
   290  									"org_id":     orgID,
   291  									"lower_name": strings.ToLower(organization.OwnerTeamName),
   292  								}),
   293  							"uid": userID,
   294  						}),
   295  					),
   296  				)).And(
   297  				builder.In(
   298  					"team_id", builder.Select("team_id").From("team_unit").Where(
   299  						builder.Eq{
   300  							"`team_unit`.org_id": orgID,
   301  						}.And(
   302  							builder.In("`team_unit`.type", units),
   303  						),
   304  					),
   305  				),
   306  			),
   307  		))
   308  }
   309  
   310  // issuePullAccessibleRepoCond userID must not be zero, this condition require join repository table
   311  func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organization.Organization, team *organization.Team, isPull bool) builder.Cond {
   312  	cond := builder.NewCond()
   313  	unitType := unit.TypeIssues
   314  	if isPull {
   315  		unitType = unit.TypePullRequests
   316  	}
   317  	if org != nil {
   318  		if team != nil {
   319  			cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, org.ID, team.ID, unitType)) // special team member repos
   320  		} else {
   321  			cond = cond.And(
   322  				builder.Or(
   323  					repo_model.UserOrgUnitRepoCond(repoIDstr, userID, org.ID, unitType), // team member repos
   324  					repo_model.UserOrgPublicUnitRepoCond(userID, org.ID),                // user org public non-member repos, TODO: check repo has issues
   325  				),
   326  			)
   327  		}
   328  	} else {
   329  		cond = cond.And(
   330  			builder.Or(
   331  				repo_model.UserOwnedRepoCond(userID),                          // owned repos
   332  				repo_model.UserAccessRepoCond(repoIDstr, userID),              // user can access repo in a unit independent way
   333  				repo_model.UserAssignedRepoCond(repoIDstr, userID),            // user has been assigned accessible public repos
   334  				repo_model.UserMentionedRepoCond(repoIDstr, userID),           // user has been mentioned accessible public repos
   335  				repo_model.UserCreateIssueRepoCond(repoIDstr, userID, isPull), // user has created issue/pr accessible public repos
   336  			),
   337  		)
   338  	}
   339  	return cond
   340  }
   341  
   342  func applyAssigneeCondition(sess *xorm.Session, assigneeID int64) *xorm.Session {
   343  	return sess.Join("INNER", "issue_assignees", "issue.id = issue_assignees.issue_id").
   344  		And("issue_assignees.assignee_id = ?", assigneeID)
   345  }
   346  
   347  func applyPosterCondition(sess *xorm.Session, posterID int64) *xorm.Session {
   348  	return sess.And("issue.poster_id=?", posterID)
   349  }
   350  
   351  func applyMentionedCondition(sess *xorm.Session, mentionedID int64) *xorm.Session {
   352  	return sess.Join("INNER", "issue_user", "issue.id = issue_user.issue_id").
   353  		And("issue_user.is_mentioned = ?", true).
   354  		And("issue_user.uid = ?", mentionedID)
   355  }
   356  
   357  func applyReviewRequestedCondition(sess *xorm.Session, reviewRequestedID int64) *xorm.Session {
   358  	existInTeamQuery := builder.Select("team_user.team_id").
   359  		From("team_user").
   360  		Where(builder.Eq{"team_user.uid": reviewRequestedID})
   361  
   362  	// if the review is approved or rejected, it should not be shown in the review requested list
   363  	maxReview := builder.Select("MAX(r.id)").
   364  		From("review as r").
   365  		Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
   366  		GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
   367  
   368  	subQuery := builder.Select("review.issue_id").
   369  		From("review").
   370  		Where(builder.And(
   371  			builder.Eq{"review.type": ReviewTypeRequest},
   372  			builder.Or(
   373  				builder.Eq{"review.reviewer_id": reviewRequestedID},
   374  				builder.In("review.reviewer_team_id", existInTeamQuery),
   375  			),
   376  			builder.In("review.id", maxReview),
   377  		))
   378  	return sess.Where("issue.poster_id <> ?", reviewRequestedID).
   379  		And(builder.In("issue.id", subQuery))
   380  }
   381  
   382  func applyReviewedCondition(sess *xorm.Session, reviewedID int64) *xorm.Session {
   383  	// Query for pull requests where you are a reviewer or commenter, excluding
   384  	// any pull requests already returned by the review requested filter.
   385  	notPoster := builder.Neq{"issue.poster_id": reviewedID}
   386  	reviewed := builder.In("issue.id", builder.
   387  		Select("issue_id").
   388  		From("review").
   389  		Where(builder.And(
   390  			builder.Neq{"type": ReviewTypeRequest},
   391  			builder.Or(
   392  				builder.Eq{"reviewer_id": reviewedID},
   393  				builder.In("reviewer_team_id", builder.
   394  					Select("team_id").
   395  					From("team_user").
   396  					Where(builder.Eq{"uid": reviewedID}),
   397  				),
   398  			),
   399  		)),
   400  	)
   401  	commented := builder.In("issue.id", builder.
   402  		Select("issue_id").
   403  		From("comment").
   404  		Where(builder.And(
   405  			builder.Eq{"poster_id": reviewedID},
   406  			builder.In("type", CommentTypeComment, CommentTypeCode, CommentTypeReview),
   407  		)),
   408  	)
   409  	return sess.And(notPoster, builder.Or(reviewed, commented))
   410  }
   411  
   412  func applySubscribedCondition(sess *xorm.Session, subscriberID int64) *xorm.Session {
   413  	return sess.And(
   414  		builder.
   415  			NotIn("issue.id",
   416  				builder.Select("issue_id").
   417  					From("issue_watch").
   418  					Where(builder.Eq{"is_watching": false, "user_id": subscriberID}),
   419  			),
   420  	).And(
   421  		builder.Or(
   422  			builder.In("issue.id", builder.
   423  				Select("issue_id").
   424  				From("issue_watch").
   425  				Where(builder.Eq{"is_watching": true, "user_id": subscriberID}),
   426  			),
   427  			builder.In("issue.id", builder.
   428  				Select("issue_id").
   429  				From("comment").
   430  				Where(builder.Eq{"poster_id": subscriberID}),
   431  			),
   432  			builder.Eq{"issue.poster_id": subscriberID},
   433  			builder.In("issue.repo_id", builder.
   434  				Select("id").
   435  				From("watch").
   436  				Where(builder.And(builder.Eq{"user_id": subscriberID},
   437  					builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
   438  			),
   439  		),
   440  	)
   441  }
   442  
   443  // Issues returns a list of issues by given conditions.
   444  func Issues(ctx context.Context, opts *IssuesOptions) (IssueList, error) {
   445  	sess := db.GetEngine(ctx).
   446  		Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
   447  	applyLimit(sess, opts)
   448  	applyConditions(sess, opts)
   449  	applySorts(sess, opts.SortType, opts.PriorityRepoID)
   450  
   451  	issues := IssueList{}
   452  	if err := sess.Find(&issues); err != nil {
   453  		return nil, fmt.Errorf("unable to query Issues: %w", err)
   454  	}
   455  
   456  	if err := issues.LoadAttributes(ctx); err != nil {
   457  		return nil, fmt.Errorf("unable to LoadAttributes for Issues: %w", err)
   458  	}
   459  
   460  	return issues, nil
   461  }
   462  
   463  // IssueIDs returns a list of issue ids by given conditions.
   464  func IssueIDs(ctx context.Context, opts *IssuesOptions, otherConds ...builder.Cond) ([]int64, int64, error) {
   465  	sess := db.GetEngine(ctx).
   466  		Join("INNER", "repository", "`issue`.repo_id = `repository`.id")
   467  	applyConditions(sess, opts)
   468  	for _, cond := range otherConds {
   469  		sess.And(cond)
   470  	}
   471  
   472  	applyLimit(sess, opts)
   473  	applySorts(sess, opts.SortType, opts.PriorityRepoID)
   474  
   475  	var res []int64
   476  	total, err := sess.Select("`issue`.id").Table(&Issue{}).FindAndCount(&res)
   477  	if err != nil {
   478  		return nil, 0, err
   479  	}
   480  
   481  	return res, total, nil
   482  }