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

     1  // Copyright 2018 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package issues
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"slices"
    10  	"strings"
    11  
    12  	"code.gitea.io/gitea/models/db"
    13  	git_model "code.gitea.io/gitea/models/git"
    14  	"code.gitea.io/gitea/models/organization"
    15  	"code.gitea.io/gitea/models/perm"
    16  	access_model "code.gitea.io/gitea/models/perm/access"
    17  	"code.gitea.io/gitea/models/unit"
    18  	user_model "code.gitea.io/gitea/models/user"
    19  	"code.gitea.io/gitea/modules/structs"
    20  	"code.gitea.io/gitea/modules/timeutil"
    21  	"code.gitea.io/gitea/modules/util"
    22  
    23  	"xorm.io/builder"
    24  )
    25  
    26  // ErrReviewNotExist represents a "ReviewNotExist" kind of error.
    27  type ErrReviewNotExist struct {
    28  	ID int64
    29  }
    30  
    31  // IsErrReviewNotExist checks if an error is a ErrReviewNotExist.
    32  func IsErrReviewNotExist(err error) bool {
    33  	_, ok := err.(ErrReviewNotExist)
    34  	return ok
    35  }
    36  
    37  func (err ErrReviewNotExist) Error() string {
    38  	return fmt.Sprintf("review does not exist [id: %d]", err.ID)
    39  }
    40  
    41  func (err ErrReviewNotExist) Unwrap() error {
    42  	return util.ErrNotExist
    43  }
    44  
    45  // ErrNotValidReviewRequest an not allowed review request modify
    46  type ErrNotValidReviewRequest struct {
    47  	Reason string
    48  	UserID int64
    49  	RepoID int64
    50  }
    51  
    52  // IsErrNotValidReviewRequest checks if an error is a ErrNotValidReviewRequest.
    53  func IsErrNotValidReviewRequest(err error) bool {
    54  	_, ok := err.(ErrNotValidReviewRequest)
    55  	return ok
    56  }
    57  
    58  func (err ErrNotValidReviewRequest) Error() string {
    59  	return fmt.Sprintf("%s [user_id: %d, repo_id: %d]",
    60  		err.Reason,
    61  		err.UserID,
    62  		err.RepoID)
    63  }
    64  
    65  func (err ErrNotValidReviewRequest) Unwrap() error {
    66  	return util.ErrInvalidArgument
    67  }
    68  
    69  // ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR.
    70  type ErrReviewRequestOnClosedPR struct{}
    71  
    72  // IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR.
    73  func IsErrReviewRequestOnClosedPR(err error) bool {
    74  	_, ok := err.(ErrReviewRequestOnClosedPR)
    75  	return ok
    76  }
    77  
    78  func (err ErrReviewRequestOnClosedPR) Error() string {
    79  	return "cannot request a re-review on a closed or merged PR"
    80  }
    81  
    82  func (err ErrReviewRequestOnClosedPR) Unwrap() error {
    83  	return util.ErrPermissionDenied
    84  }
    85  
    86  // ReviewType defines the sort of feedback a review gives
    87  type ReviewType int
    88  
    89  // ReviewTypeUnknown unknown review type
    90  const ReviewTypeUnknown ReviewType = -1
    91  
    92  const (
    93  	// ReviewTypePending is a review which is not published yet
    94  	ReviewTypePending ReviewType = iota
    95  	// ReviewTypeApprove approves changes
    96  	ReviewTypeApprove
    97  	// ReviewTypeComment gives general feedback
    98  	ReviewTypeComment
    99  	// ReviewTypeReject gives feedback blocking merge
   100  	ReviewTypeReject
   101  	// ReviewTypeRequest request review from others
   102  	ReviewTypeRequest
   103  )
   104  
   105  // Icon returns the corresponding icon for the review type
   106  func (rt ReviewType) Icon() string {
   107  	switch rt {
   108  	case ReviewTypeApprove:
   109  		return "check"
   110  	case ReviewTypeReject:
   111  		return "diff"
   112  	case ReviewTypeComment:
   113  		return "comment"
   114  	case ReviewTypeRequest:
   115  		return "dot-fill"
   116  	default:
   117  		return "comment"
   118  	}
   119  }
   120  
   121  // Review represents collection of code comments giving feedback for a PR
   122  type Review struct {
   123  	ID               int64 `xorm:"pk autoincr"`
   124  	Type             ReviewType
   125  	Reviewer         *user_model.User   `xorm:"-"`
   126  	ReviewerID       int64              `xorm:"index"`
   127  	ReviewerTeamID   int64              `xorm:"NOT NULL DEFAULT 0"`
   128  	ReviewerTeam     *organization.Team `xorm:"-"`
   129  	OriginalAuthor   string
   130  	OriginalAuthorID int64
   131  	Issue            *Issue `xorm:"-"`
   132  	IssueID          int64  `xorm:"index"`
   133  	Content          string `xorm:"TEXT"`
   134  	// Official is a review made by an assigned approver (counts towards approval)
   135  	Official  bool   `xorm:"NOT NULL DEFAULT false"`
   136  	CommitID  string `xorm:"VARCHAR(64)"`
   137  	Stale     bool   `xorm:"NOT NULL DEFAULT false"`
   138  	Dismissed bool   `xorm:"NOT NULL DEFAULT false"`
   139  
   140  	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
   141  	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
   142  
   143  	// CodeComments are the initial code comments of the review
   144  	CodeComments CodeComments `xorm:"-"`
   145  
   146  	Comments []*Comment `xorm:"-"`
   147  }
   148  
   149  func init() {
   150  	db.RegisterModel(new(Review))
   151  }
   152  
   153  // LoadCodeComments loads CodeComments
   154  func (r *Review) LoadCodeComments(ctx context.Context) (err error) {
   155  	if r.CodeComments != nil {
   156  		return err
   157  	}
   158  	if err = r.LoadIssue(ctx); err != nil {
   159  		return err
   160  	}
   161  	r.CodeComments, err = fetchCodeCommentsByReview(ctx, r.Issue, nil, r, false)
   162  	return err
   163  }
   164  
   165  func (r *Review) LoadIssue(ctx context.Context) (err error) {
   166  	if r.Issue != nil {
   167  		return err
   168  	}
   169  	r.Issue, err = GetIssueByID(ctx, r.IssueID)
   170  	return err
   171  }
   172  
   173  // LoadReviewer loads reviewer
   174  func (r *Review) LoadReviewer(ctx context.Context) (err error) {
   175  	if r.ReviewerID == 0 || r.Reviewer != nil {
   176  		return err
   177  	}
   178  	r.Reviewer, err = user_model.GetPossibleUserByID(ctx, r.ReviewerID)
   179  	if err != nil {
   180  		if !user_model.IsErrUserNotExist(err) {
   181  			return fmt.Errorf("GetPossibleUserByID [%d]: %w", r.ReviewerID, err)
   182  		}
   183  		r.ReviewerID = user_model.GhostUserID
   184  		r.Reviewer = user_model.NewGhostUser()
   185  		return nil
   186  	}
   187  	return err
   188  }
   189  
   190  // LoadReviewerTeam loads reviewer team
   191  func (r *Review) LoadReviewerTeam(ctx context.Context) (err error) {
   192  	if r.ReviewerTeamID == 0 || r.ReviewerTeam != nil {
   193  		return nil
   194  	}
   195  
   196  	r.ReviewerTeam, err = organization.GetTeamByID(ctx, r.ReviewerTeamID)
   197  	return err
   198  }
   199  
   200  // LoadAttributes loads all attributes except CodeComments
   201  func (r *Review) LoadAttributes(ctx context.Context) (err error) {
   202  	if err = r.LoadIssue(ctx); err != nil {
   203  		return err
   204  	}
   205  	if err = r.LoadCodeComments(ctx); err != nil {
   206  		return err
   207  	}
   208  	if err = r.LoadReviewer(ctx); err != nil {
   209  		return err
   210  	}
   211  	if err = r.LoadReviewerTeam(ctx); err != nil {
   212  		return err
   213  	}
   214  	return err
   215  }
   216  
   217  func (r *Review) HTMLTypeColorName() string {
   218  	switch r.Type {
   219  	case ReviewTypeApprove:
   220  		if r.Stale {
   221  			return "yellow"
   222  		}
   223  		return "green"
   224  	case ReviewTypeComment:
   225  		return "grey"
   226  	case ReviewTypeReject:
   227  		return "red"
   228  	case ReviewTypeRequest:
   229  		return "yellow"
   230  	}
   231  	return "grey"
   232  }
   233  
   234  // GetReviewByID returns the review by the given ID
   235  func GetReviewByID(ctx context.Context, id int64) (*Review, error) {
   236  	review := new(Review)
   237  	if has, err := db.GetEngine(ctx).ID(id).Get(review); err != nil {
   238  		return nil, err
   239  	} else if !has {
   240  		return nil, ErrReviewNotExist{ID: id}
   241  	}
   242  	return review, nil
   243  }
   244  
   245  // CreateReviewOptions represent the options to create a review. Type, Issue and Reviewer are required.
   246  type CreateReviewOptions struct {
   247  	Content      string
   248  	Type         ReviewType
   249  	Issue        *Issue
   250  	Reviewer     *user_model.User
   251  	ReviewerTeam *organization.Team
   252  	Official     bool
   253  	CommitID     string
   254  	Stale        bool
   255  }
   256  
   257  // IsOfficialReviewer check if at least one of the provided reviewers can make official reviews in issue (counts towards required approvals)
   258  func IsOfficialReviewer(ctx context.Context, issue *Issue, reviewer *user_model.User) (bool, error) {
   259  	if err := issue.LoadPullRequest(ctx); err != nil {
   260  		return false, err
   261  	}
   262  
   263  	pr := issue.PullRequest
   264  	rule, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
   265  	if err != nil {
   266  		return false, err
   267  	}
   268  	if rule == nil {
   269  		// if no rule is found, then user with write access can make official reviews
   270  		err := pr.LoadBaseRepo(ctx)
   271  		if err != nil {
   272  			return false, err
   273  		}
   274  		writeAccess, err := access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite)
   275  		if err != nil {
   276  			return false, err
   277  		}
   278  		return writeAccess, nil
   279  	}
   280  
   281  	official, err := git_model.IsUserOfficialReviewer(ctx, rule, reviewer)
   282  	if official || err != nil {
   283  		return official, err
   284  	}
   285  
   286  	return false, nil
   287  }
   288  
   289  // IsOfficialReviewerTeam check if reviewer in this team can make official reviews in issue (counts towards required approvals)
   290  func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organization.Team) (bool, error) {
   291  	if err := issue.LoadPullRequest(ctx); err != nil {
   292  		return false, err
   293  	}
   294  	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, issue.PullRequest.BaseRepoID, issue.PullRequest.BaseBranch)
   295  	if err != nil {
   296  		return false, err
   297  	}
   298  	if pb == nil {
   299  		return false, nil
   300  	}
   301  
   302  	if !pb.EnableApprovalsWhitelist {
   303  		return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
   304  	}
   305  
   306  	return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
   307  }
   308  
   309  // CreateReview creates a new review based on opts
   310  func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
   311  	ctx, committer, err := db.TxContext(ctx)
   312  	if err != nil {
   313  		return nil, err
   314  	}
   315  	defer committer.Close()
   316  	sess := db.GetEngine(ctx)
   317  
   318  	review := &Review{
   319  		Issue:        opts.Issue,
   320  		IssueID:      opts.Issue.ID,
   321  		Reviewer:     opts.Reviewer,
   322  		ReviewerTeam: opts.ReviewerTeam,
   323  		Content:      opts.Content,
   324  		Official:     opts.Official,
   325  		CommitID:     opts.CommitID,
   326  		Stale:        opts.Stale,
   327  	}
   328  
   329  	if opts.Reviewer != nil {
   330  		review.Type = opts.Type
   331  		review.ReviewerID = opts.Reviewer.ID
   332  
   333  		reviewCond := builder.Eq{"reviewer_id": opts.Reviewer.ID, "issue_id": opts.Issue.ID}
   334  		// make sure user review requests are cleared
   335  		if opts.Type != ReviewTypePending {
   336  			if _, err := sess.Where(reviewCond.And(builder.Eq{"type": ReviewTypeRequest})).Delete(new(Review)); err != nil {
   337  				return nil, err
   338  			}
   339  		}
   340  		// make sure if the created review gets dismissed no old review surface
   341  		// other types can be ignored, as they don't affect branch protection
   342  		if opts.Type == ReviewTypeApprove || opts.Type == ReviewTypeReject {
   343  			if _, err := sess.Where(reviewCond.And(builder.In("type", ReviewTypeApprove, ReviewTypeReject))).
   344  				Cols("dismissed").Update(&Review{Dismissed: true}); err != nil {
   345  				return nil, err
   346  			}
   347  		}
   348  	} else if opts.ReviewerTeam != nil {
   349  		review.Type = ReviewTypeRequest
   350  		review.ReviewerTeamID = opts.ReviewerTeam.ID
   351  	} else {
   352  		return nil, fmt.Errorf("provide either reviewer or reviewer team")
   353  	}
   354  
   355  	if _, err := sess.Insert(review); err != nil {
   356  		return nil, err
   357  	}
   358  	return review, committer.Commit()
   359  }
   360  
   361  // GetCurrentReview returns the current pending review of reviewer for given issue
   362  func GetCurrentReview(ctx context.Context, reviewer *user_model.User, issue *Issue) (*Review, error) {
   363  	if reviewer == nil {
   364  		return nil, nil
   365  	}
   366  	reviews, err := FindReviews(ctx, FindReviewOptions{
   367  		Types:      []ReviewType{ReviewTypePending},
   368  		IssueID:    issue.ID,
   369  		ReviewerID: reviewer.ID,
   370  	})
   371  	if err != nil {
   372  		return nil, err
   373  	}
   374  	if len(reviews) == 0 {
   375  		return nil, ErrReviewNotExist{}
   376  	}
   377  	reviews[0].Reviewer = reviewer
   378  	reviews[0].Issue = issue
   379  	return reviews[0], nil
   380  }
   381  
   382  // ReviewExists returns whether a review exists for a particular line of code in the PR
   383  func ReviewExists(ctx context.Context, issue *Issue, treePath string, line int64) (bool, error) {
   384  	return db.GetEngine(ctx).Cols("id").Exist(&Comment{IssueID: issue.ID, TreePath: treePath, Line: line, Type: CommentTypeCode})
   385  }
   386  
   387  // ContentEmptyErr represents an content empty error
   388  type ContentEmptyErr struct{}
   389  
   390  func (ContentEmptyErr) Error() string {
   391  	return "Review content is empty"
   392  }
   393  
   394  // IsContentEmptyErr returns true if err is a ContentEmptyErr
   395  func IsContentEmptyErr(err error) bool {
   396  	_, ok := err.(ContentEmptyErr)
   397  	return ok
   398  }
   399  
   400  // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
   401  func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, reviewType ReviewType, content, commitID string, stale bool, attachmentUUIDs []string) (*Review, *Comment, error) {
   402  	ctx, committer, err := db.TxContext(ctx)
   403  	if err != nil {
   404  		return nil, nil, err
   405  	}
   406  	defer committer.Close()
   407  	sess := db.GetEngine(ctx)
   408  
   409  	official := false
   410  
   411  	review, err := GetCurrentReview(ctx, doer, issue)
   412  	if err != nil {
   413  		if !IsErrReviewNotExist(err) {
   414  			return nil, nil, err
   415  		}
   416  
   417  		if reviewType != ReviewTypeApprove && len(strings.TrimSpace(content)) == 0 {
   418  			return nil, nil, ContentEmptyErr{}
   419  		}
   420  
   421  		if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
   422  			// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
   423  			if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
   424  				return nil, nil, err
   425  			}
   426  			if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
   427  				return nil, nil, err
   428  			}
   429  		}
   430  
   431  		// No current review. Create a new one!
   432  		if review, err = CreateReview(ctx, CreateReviewOptions{
   433  			Type:     reviewType,
   434  			Issue:    issue,
   435  			Reviewer: doer,
   436  			Content:  content,
   437  			Official: official,
   438  			CommitID: commitID,
   439  			Stale:    stale,
   440  		}); err != nil {
   441  			return nil, nil, err
   442  		}
   443  	} else {
   444  		if err := review.LoadCodeComments(ctx); err != nil {
   445  			return nil, nil, err
   446  		}
   447  		if reviewType != ReviewTypeApprove && len(review.CodeComments) == 0 && len(strings.TrimSpace(content)) == 0 {
   448  			return nil, nil, ContentEmptyErr{}
   449  		}
   450  
   451  		if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
   452  			// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
   453  			if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
   454  				return nil, nil, err
   455  			}
   456  			if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
   457  				return nil, nil, err
   458  			}
   459  		}
   460  
   461  		review.Official = official
   462  		review.Issue = issue
   463  		review.Content = content
   464  		review.Type = reviewType
   465  		review.CommitID = commitID
   466  		review.Stale = stale
   467  
   468  		if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil {
   469  			return nil, nil, err
   470  		}
   471  	}
   472  
   473  	comm, err := CreateComment(ctx, &CreateCommentOptions{
   474  		Type:        CommentTypeReview,
   475  		Doer:        doer,
   476  		Content:     review.Content,
   477  		Issue:       issue,
   478  		Repo:        issue.Repo,
   479  		ReviewID:    review.ID,
   480  		Attachments: attachmentUUIDs,
   481  	})
   482  	if err != nil || comm == nil {
   483  		return nil, nil, err
   484  	}
   485  
   486  	// try to remove team review request if need
   487  	if issue.Repo.Owner.IsOrganization() && (reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject) {
   488  		teamReviewRequests := make([]*Review, 0, 10)
   489  		if err := sess.SQL("SELECT * FROM review WHERE issue_id = ? AND reviewer_team_id > 0 AND type = ?", issue.ID, ReviewTypeRequest).Find(&teamReviewRequests); err != nil {
   490  			return nil, nil, err
   491  		}
   492  
   493  		for _, teamReviewRequest := range teamReviewRequests {
   494  			ok, err := organization.IsTeamMember(ctx, issue.Repo.OwnerID, teamReviewRequest.ReviewerTeamID, doer.ID)
   495  			if err != nil {
   496  				return nil, nil, err
   497  			} else if !ok {
   498  				continue
   499  			}
   500  
   501  			if _, err := db.DeleteByID[Review](ctx, teamReviewRequest.ID); err != nil {
   502  				return nil, nil, err
   503  			}
   504  		}
   505  	}
   506  
   507  	comm.Review = review
   508  	return review, comm, committer.Commit()
   509  }
   510  
   511  // GetReviewByIssueIDAndUserID get the latest review of reviewer for a pull request
   512  func GetReviewByIssueIDAndUserID(ctx context.Context, issueID, userID int64) (*Review, error) {
   513  	review := new(Review)
   514  
   515  	has, err := db.GetEngine(ctx).Where(
   516  		builder.In("type", ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest).
   517  			And(builder.Eq{"issue_id": issueID, "reviewer_id": userID, "original_author_id": 0})).
   518  		Desc("id").
   519  		Get(review)
   520  	if err != nil {
   521  		return nil, err
   522  	}
   523  
   524  	if !has {
   525  		return nil, ErrReviewNotExist{}
   526  	}
   527  
   528  	return review, nil
   529  }
   530  
   531  // GetTeamReviewerByIssueIDAndTeamID get the latest review request of reviewer team for a pull request
   532  func GetTeamReviewerByIssueIDAndTeamID(ctx context.Context, issueID, teamID int64) (*Review, error) {
   533  	review := new(Review)
   534  
   535  	has, err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issueID, "reviewer_team_id": teamID}).
   536  		Desc("id").
   537  		Get(review)
   538  	if err != nil {
   539  		return nil, err
   540  	}
   541  
   542  	if !has {
   543  		return nil, ErrReviewNotExist{0}
   544  	}
   545  
   546  	return review, err
   547  }
   548  
   549  // MarkReviewsAsStale marks existing reviews as stale
   550  func MarkReviewsAsStale(ctx context.Context, issueID int64) (err error) {
   551  	_, err = db.GetEngine(ctx).Exec("UPDATE `review` SET stale=? WHERE issue_id=?", true, issueID)
   552  
   553  	return err
   554  }
   555  
   556  // MarkReviewsAsNotStale marks existing reviews as not stale for a giving commit SHA
   557  func MarkReviewsAsNotStale(ctx context.Context, issueID int64, commitID string) (err error) {
   558  	_, err = db.GetEngine(ctx).Exec("UPDATE `review` SET stale=? WHERE issue_id=? AND commit_id=?", false, issueID, commitID)
   559  
   560  	return err
   561  }
   562  
   563  // DismissReview change the dismiss status of a review
   564  func DismissReview(ctx context.Context, review *Review, isDismiss bool) (err error) {
   565  	if review.Dismissed == isDismiss || (review.Type != ReviewTypeApprove && review.Type != ReviewTypeReject) {
   566  		return nil
   567  	}
   568  
   569  	review.Dismissed = isDismiss
   570  
   571  	if review.ID == 0 {
   572  		return ErrReviewNotExist{}
   573  	}
   574  
   575  	_, err = db.GetEngine(ctx).ID(review.ID).Cols("dismissed").Update(review)
   576  
   577  	return err
   578  }
   579  
   580  // InsertReviews inserts review and review comments
   581  func InsertReviews(ctx context.Context, reviews []*Review) error {
   582  	ctx, committer, err := db.TxContext(ctx)
   583  	if err != nil {
   584  		return err
   585  	}
   586  	defer committer.Close()
   587  	sess := db.GetEngine(ctx)
   588  
   589  	for _, review := range reviews {
   590  		if _, err := sess.NoAutoTime().Insert(review); err != nil {
   591  			return err
   592  		}
   593  
   594  		if _, err := sess.NoAutoTime().Insert(&Comment{
   595  			Type:             CommentTypeReview,
   596  			Content:          review.Content,
   597  			PosterID:         review.ReviewerID,
   598  			OriginalAuthor:   review.OriginalAuthor,
   599  			OriginalAuthorID: review.OriginalAuthorID,
   600  			IssueID:          review.IssueID,
   601  			ReviewID:         review.ID,
   602  			CreatedUnix:      review.CreatedUnix,
   603  			UpdatedUnix:      review.UpdatedUnix,
   604  		}); err != nil {
   605  			return err
   606  		}
   607  
   608  		for _, c := range review.Comments {
   609  			c.ReviewID = review.ID
   610  		}
   611  
   612  		if len(review.Comments) > 0 {
   613  			if _, err := sess.NoAutoTime().Insert(review.Comments); err != nil {
   614  				return err
   615  			}
   616  		}
   617  	}
   618  
   619  	return committer.Commit()
   620  }
   621  
   622  // AddReviewRequest add a review request from one reviewer
   623  func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) {
   624  	ctx, committer, err := db.TxContext(ctx)
   625  	if err != nil {
   626  		return nil, err
   627  	}
   628  	defer committer.Close()
   629  	sess := db.GetEngine(ctx)
   630  
   631  	review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
   632  	if err != nil && !IsErrReviewNotExist(err) {
   633  		return nil, err
   634  	}
   635  
   636  	if review != nil {
   637  		// skip it when reviewer hase been request to review
   638  		if review.Type == ReviewTypeRequest {
   639  			return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction.
   640  		}
   641  
   642  		if issue.IsClosed {
   643  			return nil, ErrReviewRequestOnClosedPR{}
   644  		}
   645  
   646  		if issue.IsPull {
   647  			if err := issue.LoadPullRequest(ctx); err != nil {
   648  				return nil, err
   649  			}
   650  			if issue.PullRequest.HasMerged {
   651  				return nil, ErrReviewRequestOnClosedPR{}
   652  			}
   653  		}
   654  	}
   655  
   656  	// if the reviewer is an official reviewer,
   657  	// remove the official flag in the all previous reviews
   658  	official, err := IsOfficialReviewer(ctx, issue, reviewer)
   659  	if err != nil {
   660  		return nil, err
   661  	} else if official {
   662  		if _, err := sess.Exec("UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, reviewer.ID); err != nil {
   663  			return nil, err
   664  		}
   665  	}
   666  
   667  	review, err = CreateReview(ctx, CreateReviewOptions{
   668  		Type:     ReviewTypeRequest,
   669  		Issue:    issue,
   670  		Reviewer: reviewer,
   671  		Official: official,
   672  		Stale:    false,
   673  	})
   674  	if err != nil {
   675  		return nil, err
   676  	}
   677  
   678  	comment, err := CreateComment(ctx, &CreateCommentOptions{
   679  		Type:            CommentTypeReviewRequest,
   680  		Doer:            doer,
   681  		Repo:            issue.Repo,
   682  		Issue:           issue,
   683  		RemovedAssignee: false,       // Use RemovedAssignee as !isRequest
   684  		AssigneeID:      reviewer.ID, // Use AssigneeID as reviewer ID
   685  		ReviewID:        review.ID,
   686  	})
   687  	if err != nil {
   688  		return nil, err
   689  	}
   690  
   691  	// func caller use the created comment to retrieve created review too.
   692  	comment.Review = review
   693  
   694  	return comment, committer.Commit()
   695  }
   696  
   697  // RemoveReviewRequest remove a review request from one reviewer
   698  func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) {
   699  	ctx, committer, err := db.TxContext(ctx)
   700  	if err != nil {
   701  		return nil, err
   702  	}
   703  	defer committer.Close()
   704  
   705  	review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
   706  	if err != nil && !IsErrReviewNotExist(err) {
   707  		return nil, err
   708  	}
   709  
   710  	if review == nil || review.Type != ReviewTypeRequest {
   711  		return nil, nil
   712  	}
   713  
   714  	if _, err = db.DeleteByBean(ctx, review); err != nil {
   715  		return nil, err
   716  	}
   717  
   718  	official, err := IsOfficialReviewer(ctx, issue, reviewer)
   719  	if err != nil {
   720  		return nil, err
   721  	} else if official {
   722  		if err := restoreLatestOfficialReview(ctx, issue.ID, reviewer.ID); err != nil {
   723  			return nil, err
   724  		}
   725  	}
   726  
   727  	comment, err := CreateComment(ctx, &CreateCommentOptions{
   728  		Type:            CommentTypeReviewRequest,
   729  		Doer:            doer,
   730  		Repo:            issue.Repo,
   731  		Issue:           issue,
   732  		RemovedAssignee: true,        // Use RemovedAssignee as !isRequest
   733  		AssigneeID:      reviewer.ID, // Use AssigneeID as reviewer ID
   734  	})
   735  	if err != nil {
   736  		return nil, err
   737  	}
   738  
   739  	return comment, committer.Commit()
   740  }
   741  
   742  // Recalculate the latest official review for reviewer
   743  func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) error {
   744  	review, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID)
   745  	if err != nil && !IsErrReviewNotExist(err) {
   746  		return err
   747  	}
   748  
   749  	if review != nil {
   750  		if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
   751  			return err
   752  		}
   753  	}
   754  
   755  	return nil
   756  }
   757  
   758  // AddTeamReviewRequest add a review request from one team
   759  func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) {
   760  	ctx, committer, err := db.TxContext(ctx)
   761  	if err != nil {
   762  		return nil, err
   763  	}
   764  	defer committer.Close()
   765  
   766  	review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID)
   767  	if err != nil && !IsErrReviewNotExist(err) {
   768  		return nil, err
   769  	}
   770  
   771  	// This team already has been requested to review - therefore skip this.
   772  	if review != nil {
   773  		return nil, nil
   774  	}
   775  
   776  	official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
   777  	if err != nil {
   778  		return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
   779  	} else if !official {
   780  		if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
   781  			return nil, fmt.Errorf("isOfficialReviewer(): %w", err)
   782  		}
   783  	}
   784  
   785  	if review, err = CreateReview(ctx, CreateReviewOptions{
   786  		Type:         ReviewTypeRequest,
   787  		Issue:        issue,
   788  		ReviewerTeam: reviewer,
   789  		Official:     official,
   790  		Stale:        false,
   791  	}); err != nil {
   792  		return nil, err
   793  	}
   794  
   795  	if official {
   796  		if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_team_id=?", false, issue.ID, reviewer.ID); err != nil {
   797  			return nil, err
   798  		}
   799  	}
   800  
   801  	comment, err := CreateComment(ctx, &CreateCommentOptions{
   802  		Type:            CommentTypeReviewRequest,
   803  		Doer:            doer,
   804  		Repo:            issue.Repo,
   805  		Issue:           issue,
   806  		RemovedAssignee: false,       // Use RemovedAssignee as !isRequest
   807  		AssigneeTeamID:  reviewer.ID, // Use AssigneeTeamID as reviewer team ID
   808  		ReviewID:        review.ID,
   809  	})
   810  	if err != nil {
   811  		return nil, fmt.Errorf("CreateComment(): %w", err)
   812  	}
   813  
   814  	return comment, committer.Commit()
   815  }
   816  
   817  // RemoveTeamReviewRequest remove a review request from one team
   818  func RemoveTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) {
   819  	ctx, committer, err := db.TxContext(ctx)
   820  	if err != nil {
   821  		return nil, err
   822  	}
   823  	defer committer.Close()
   824  
   825  	review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID)
   826  	if err != nil && !IsErrReviewNotExist(err) {
   827  		return nil, err
   828  	}
   829  
   830  	if review == nil {
   831  		return nil, nil
   832  	}
   833  
   834  	if _, err = db.DeleteByBean(ctx, review); err != nil {
   835  		return nil, err
   836  	}
   837  
   838  	official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
   839  	if err != nil {
   840  		return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
   841  	}
   842  
   843  	if official {
   844  		// recalculate which is the latest official review from that team
   845  		review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, -reviewer.ID)
   846  		if err != nil && !IsErrReviewNotExist(err) {
   847  			return nil, err
   848  		}
   849  
   850  		if review != nil {
   851  			if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
   852  				return nil, err
   853  			}
   854  		}
   855  	}
   856  
   857  	if doer == nil {
   858  		return nil, committer.Commit()
   859  	}
   860  
   861  	comment, err := CreateComment(ctx, &CreateCommentOptions{
   862  		Type:            CommentTypeReviewRequest,
   863  		Doer:            doer,
   864  		Repo:            issue.Repo,
   865  		Issue:           issue,
   866  		RemovedAssignee: true,        // Use RemovedAssignee as !isRequest
   867  		AssigneeTeamID:  reviewer.ID, // Use AssigneeTeamID as reviewer team ID
   868  	})
   869  	if err != nil {
   870  		return nil, fmt.Errorf("CreateComment(): %w", err)
   871  	}
   872  
   873  	return comment, committer.Commit()
   874  }
   875  
   876  // MarkConversation Add or remove Conversation mark for a code comment
   877  func MarkConversation(ctx context.Context, comment *Comment, doer *user_model.User, isResolve bool) (err error) {
   878  	if comment.Type != CommentTypeCode {
   879  		return nil
   880  	}
   881  
   882  	if isResolve {
   883  		if comment.ResolveDoerID != 0 {
   884  			return nil
   885  		}
   886  
   887  		if _, err = db.GetEngine(ctx).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", doer.ID, comment.ID); err != nil {
   888  			return err
   889  		}
   890  	} else {
   891  		if comment.ResolveDoerID == 0 {
   892  			return nil
   893  		}
   894  
   895  		if _, err = db.GetEngine(ctx).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", 0, comment.ID); err != nil {
   896  			return err
   897  		}
   898  	}
   899  
   900  	return nil
   901  }
   902  
   903  // CanMarkConversation  Add or remove Conversation mark for a code comment permission check
   904  // the PR writer , offfcial reviewer and poster can do it
   905  func CanMarkConversation(ctx context.Context, issue *Issue, doer *user_model.User) (permResult bool, err error) {
   906  	if doer == nil || issue == nil {
   907  		return false, fmt.Errorf("issue or doer is nil")
   908  	}
   909  
   910  	if doer.ID != issue.PosterID {
   911  		if err = issue.LoadRepo(ctx); err != nil {
   912  			return false, err
   913  		}
   914  
   915  		p, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
   916  		if err != nil {
   917  			return false, err
   918  		}
   919  
   920  		permResult = p.CanAccess(perm.AccessModeWrite, unit.TypePullRequests)
   921  		if !permResult {
   922  			if permResult, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
   923  				return false, err
   924  			}
   925  		}
   926  
   927  		if !permResult {
   928  			return false, nil
   929  		}
   930  	}
   931  
   932  	return true, nil
   933  }
   934  
   935  // DeleteReview delete a review and it's code comments
   936  func DeleteReview(ctx context.Context, r *Review) error {
   937  	ctx, committer, err := db.TxContext(ctx)
   938  	if err != nil {
   939  		return err
   940  	}
   941  	defer committer.Close()
   942  
   943  	if r.ID == 0 {
   944  		return fmt.Errorf("review is not allowed to be 0")
   945  	}
   946  
   947  	if r.Type == ReviewTypeRequest {
   948  		return fmt.Errorf("review request can not be deleted using this method")
   949  	}
   950  
   951  	opts := FindCommentsOptions{
   952  		Type:     CommentTypeCode,
   953  		IssueID:  r.IssueID,
   954  		ReviewID: r.ID,
   955  	}
   956  
   957  	if _, err := db.Delete[Comment](ctx, opts); err != nil {
   958  		return err
   959  	}
   960  
   961  	opts = FindCommentsOptions{
   962  		Type:     CommentTypeReview,
   963  		IssueID:  r.IssueID,
   964  		ReviewID: r.ID,
   965  	}
   966  
   967  	if _, err := db.Delete[Comment](ctx, opts); err != nil {
   968  		return err
   969  	}
   970  
   971  	opts = FindCommentsOptions{
   972  		Type:     CommentTypeDismissReview,
   973  		IssueID:  r.IssueID,
   974  		ReviewID: r.ID,
   975  	}
   976  
   977  	if _, err := db.Delete[Comment](ctx, opts); err != nil {
   978  		return err
   979  	}
   980  
   981  	if _, err := db.DeleteByID[Review](ctx, r.ID); err != nil {
   982  		return err
   983  	}
   984  
   985  	if r.Official {
   986  		if err := restoreLatestOfficialReview(ctx, r.IssueID, r.ReviewerID); err != nil {
   987  			return err
   988  		}
   989  	}
   990  
   991  	return committer.Commit()
   992  }
   993  
   994  // GetCodeCommentsCount return count of CodeComments a Review has
   995  func (r *Review) GetCodeCommentsCount(ctx context.Context) int {
   996  	opts := FindCommentsOptions{
   997  		Type:     CommentTypeCode,
   998  		IssueID:  r.IssueID,
   999  		ReviewID: r.ID,
  1000  	}
  1001  	conds := opts.ToConds()
  1002  	if r.ID == 0 {
  1003  		conds = conds.And(builder.Eq{"invalidated": false})
  1004  	}
  1005  
  1006  	count, err := db.GetEngine(ctx).Where(conds).Count(new(Comment))
  1007  	if err != nil {
  1008  		return 0
  1009  	}
  1010  	return int(count)
  1011  }
  1012  
  1013  // HTMLURL formats a URL-string to the related review issue-comment
  1014  func (r *Review) HTMLURL(ctx context.Context) string {
  1015  	opts := FindCommentsOptions{
  1016  		Type:     CommentTypeReview,
  1017  		IssueID:  r.IssueID,
  1018  		ReviewID: r.ID,
  1019  	}
  1020  	comment := new(Comment)
  1021  	has, err := db.GetEngine(ctx).Where(opts.ToConds()).Get(comment)
  1022  	if err != nil || !has {
  1023  		return ""
  1024  	}
  1025  	return comment.HTMLURL(ctx)
  1026  }
  1027  
  1028  // RemapExternalUser ExternalUserRemappable interface
  1029  func (r *Review) RemapExternalUser(externalName string, externalID, userID int64) error {
  1030  	r.OriginalAuthor = externalName
  1031  	r.OriginalAuthorID = externalID
  1032  	r.ReviewerID = userID
  1033  	return nil
  1034  }
  1035  
  1036  // GetUserID ExternalUserRemappable interface
  1037  func (r *Review) GetUserID() int64 { return r.ReviewerID }
  1038  
  1039  // GetExternalName ExternalUserRemappable interface
  1040  func (r *Review) GetExternalName() string { return r.OriginalAuthor }
  1041  
  1042  // GetExternalID ExternalUserRemappable interface
  1043  func (r *Review) GetExternalID() int64 { return r.OriginalAuthorID }
  1044  
  1045  // UpdateReviewsMigrationsByType updates reviews' migrations information via given git service type and original id and poster id
  1046  func UpdateReviewsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error {
  1047  	_, err := db.GetEngine(ctx).Table("review").
  1048  		Where("original_author_id = ?", originalAuthorID).
  1049  		And(migratedIssueCond(tp)).
  1050  		Update(map[string]any{
  1051  			"reviewer_id":        posterID,
  1052  			"original_author":    "",
  1053  			"original_author_id": 0,
  1054  		})
  1055  	return err
  1056  }