code.gitea.io/gitea@v1.22.3/services/pull/review.go (about)

     1  // Copyright 2019 The Gitea Authors.
     2  // All rights reserved.
     3  // SPDX-License-Identifier: MIT
     4  
     5  package pull
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"regexp"
    13  	"strings"
    14  
    15  	"code.gitea.io/gitea/models/db"
    16  	issues_model "code.gitea.io/gitea/models/issues"
    17  	repo_model "code.gitea.io/gitea/models/repo"
    18  	user_model "code.gitea.io/gitea/models/user"
    19  	"code.gitea.io/gitea/modules/git"
    20  	"code.gitea.io/gitea/modules/gitrepo"
    21  	"code.gitea.io/gitea/modules/log"
    22  	"code.gitea.io/gitea/modules/optional"
    23  	"code.gitea.io/gitea/modules/setting"
    24  	"code.gitea.io/gitea/modules/util"
    25  	notify_service "code.gitea.io/gitea/services/notify"
    26  )
    27  
    28  var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
    29  
    30  // ErrDismissRequestOnClosedPR represents an error when an user tries to dismiss a review associated to a closed or merged PR.
    31  type ErrDismissRequestOnClosedPR struct{}
    32  
    33  // IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR.
    34  func IsErrDismissRequestOnClosedPR(err error) bool {
    35  	_, ok := err.(ErrDismissRequestOnClosedPR)
    36  	return ok
    37  }
    38  
    39  func (err ErrDismissRequestOnClosedPR) Error() string {
    40  	return "can't dismiss a review associated to a closed or merged PR"
    41  }
    42  
    43  func (err ErrDismissRequestOnClosedPR) Unwrap() error {
    44  	return util.ErrPermissionDenied
    45  }
    46  
    47  // ErrSubmitReviewOnClosedPR represents an error when an user tries to submit an approve or reject review associated to a closed or merged PR.
    48  var ErrSubmitReviewOnClosedPR = errors.New("can't submit review for a closed or merged PR")
    49  
    50  // checkInvalidation checks if the line of code comment got changed by another commit.
    51  // If the line got changed the comment is going to be invalidated.
    52  func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error {
    53  	// FIXME differentiate between previous and proposed line
    54  	commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine()))
    55  	if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
    56  		c.Invalidated = true
    57  		return issues_model.UpdateCommentInvalidate(ctx, c)
    58  	}
    59  	if err != nil {
    60  		return err
    61  	}
    62  	if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
    63  		c.Invalidated = true
    64  		return issues_model.UpdateCommentInvalidate(ctx, c)
    65  	}
    66  	return nil
    67  }
    68  
    69  // InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
    70  func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *git.Repository, branch string) error {
    71  	if len(prs) == 0 {
    72  		return nil
    73  	}
    74  	issueIDs := prs.GetIssueIDs()
    75  
    76  	codeComments, err := db.Find[issues_model.Comment](ctx, issues_model.FindCommentsOptions{
    77  		ListOptions: db.ListOptionsAll,
    78  		Type:        issues_model.CommentTypeCode,
    79  		Invalidated: optional.Some(false),
    80  		IssueIDs:    issueIDs,
    81  	})
    82  	if err != nil {
    83  		return fmt.Errorf("find code comments: %v", err)
    84  	}
    85  	for _, comment := range codeComments {
    86  		if err := checkInvalidation(ctx, comment, doer, repo, branch); err != nil {
    87  			return err
    88  		}
    89  	}
    90  	return nil
    91  }
    92  
    93  // CreateCodeComment creates a comment on the code line
    94  func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) {
    95  	var (
    96  		existsReview bool
    97  		err          error
    98  	)
    99  
   100  	// CreateCodeComment() is used for:
   101  	// - Single comments
   102  	// - Comments that are part of a review
   103  	// - Comments that reply to an existing review
   104  
   105  	if !pendingReview && replyReviewID != 0 {
   106  		// It's not part of a review; maybe a reply to a review comment or a single comment.
   107  		// Check if there are reviews for that line already; if there are, this is a reply
   108  		if existsReview, err = issues_model.ReviewExists(ctx, issue, treePath, line); err != nil {
   109  			return nil, err
   110  		}
   111  	}
   112  
   113  	// Comments that are replies don't require a review header to show up in the issue view
   114  	if !pendingReview && existsReview {
   115  		if err = issue.LoadRepo(ctx); err != nil {
   116  			return nil, err
   117  		}
   118  
   119  		comment, err := createCodeComment(ctx,
   120  			doer,
   121  			issue.Repo,
   122  			issue,
   123  			content,
   124  			treePath,
   125  			line,
   126  			replyReviewID,
   127  			attachments,
   128  		)
   129  		if err != nil {
   130  			return nil, err
   131  		}
   132  
   133  		mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content)
   134  		if err != nil {
   135  			return nil, err
   136  		}
   137  
   138  		notify_service.CreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions)
   139  
   140  		return comment, nil
   141  	}
   142  
   143  	review, err := issues_model.GetCurrentReview(ctx, doer, issue)
   144  	if err != nil {
   145  		if !issues_model.IsErrReviewNotExist(err) {
   146  			return nil, err
   147  		}
   148  
   149  		if review, err = issues_model.CreateReview(ctx, issues_model.CreateReviewOptions{
   150  			Type:     issues_model.ReviewTypePending,
   151  			Reviewer: doer,
   152  			Issue:    issue,
   153  			Official: false,
   154  			CommitID: latestCommitID,
   155  		}); err != nil {
   156  			return nil, err
   157  		}
   158  	}
   159  
   160  	comment, err := createCodeComment(ctx,
   161  		doer,
   162  		issue.Repo,
   163  		issue,
   164  		content,
   165  		treePath,
   166  		line,
   167  		review.ID,
   168  		attachments,
   169  	)
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  
   174  	if !pendingReview && !existsReview {
   175  		// Submit the review we've just created so the comment shows up in the issue view
   176  		if _, _, err = SubmitReview(ctx, doer, gitRepo, issue, issues_model.ReviewTypeComment, "", latestCommitID, nil); err != nil {
   177  			return nil, err
   178  		}
   179  	}
   180  
   181  	// NOTICE: if it's a pending review the notifications will not be fired until user submit review.
   182  
   183  	return comment, nil
   184  }
   185  
   186  // createCodeComment creates a plain code comment at the specified line / path
   187  func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) {
   188  	var commitID, patch string
   189  	if err := issue.LoadPullRequest(ctx); err != nil {
   190  		return nil, fmt.Errorf("LoadPullRequest: %w", err)
   191  	}
   192  	pr := issue.PullRequest
   193  	if err := pr.LoadBaseRepo(ctx); err != nil {
   194  		return nil, fmt.Errorf("LoadBaseRepo: %w", err)
   195  	}
   196  	gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
   197  	if err != nil {
   198  		return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err)
   199  	}
   200  	defer closer.Close()
   201  
   202  	invalidated := false
   203  	head := pr.GetGitRefName()
   204  	if line > 0 {
   205  		if reviewID != 0 {
   206  			first, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{
   207  				ReviewID: reviewID,
   208  				Line:     line,
   209  				TreePath: treePath,
   210  				Type:     issues_model.CommentTypeCode,
   211  				ListOptions: db.ListOptions{
   212  					PageSize: 1,
   213  					Page:     1,
   214  				},
   215  			})
   216  			if err == nil && len(first) > 0 {
   217  				commitID = first[0].CommitSHA
   218  				invalidated = first[0].Invalidated
   219  				patch = first[0].Patch
   220  			} else if err != nil && !issues_model.IsErrCommentNotExist(err) {
   221  				return nil, fmt.Errorf("Find first comment for %d line %d path %s. Error: %w", reviewID, line, treePath, err)
   222  			} else {
   223  				review, err := issues_model.GetReviewByID(ctx, reviewID)
   224  				if err == nil && len(review.CommitID) > 0 {
   225  					head = review.CommitID
   226  				} else if err != nil && !issues_model.IsErrReviewNotExist(err) {
   227  					return nil, fmt.Errorf("GetReviewByID %d. Error: %w", reviewID, err)
   228  				}
   229  			}
   230  		}
   231  
   232  		if len(commitID) == 0 {
   233  			// FIXME validate treePath
   234  			// Get latest commit referencing the commented line
   235  			// No need for get commit for base branch changes
   236  			commit, err := gitRepo.LineBlame(head, gitRepo.Path, treePath, uint(line))
   237  			if err == nil {
   238  				commitID = commit.ID.String()
   239  			} else if !(strings.Contains(err.Error(), "exit status 128 - fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
   240  				return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitRefName(), gitRepo.Path, treePath, line, err)
   241  			}
   242  		}
   243  	}
   244  
   245  	// Only fetch diff if comment is review comment
   246  	if len(patch) == 0 && reviewID != 0 {
   247  		headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName())
   248  		if err != nil {
   249  			return nil, fmt.Errorf("GetRefCommitID[%s]: %w", pr.GetGitRefName(), err)
   250  		}
   251  		if len(commitID) == 0 {
   252  			commitID = headCommitID
   253  		}
   254  		reader, writer := io.Pipe()
   255  		defer func() {
   256  			_ = reader.Close()
   257  			_ = writer.Close()
   258  		}()
   259  		go func() {
   260  			if err := git.GetRepoRawDiffForFile(gitRepo, pr.MergeBase, headCommitID, git.RawDiffNormal, treePath, writer); err != nil {
   261  				_ = writer.CloseWithError(fmt.Errorf("GetRawDiffForLine[%s, %s, %s, %s]: %w", gitRepo.Path, pr.MergeBase, headCommitID, treePath, err))
   262  				return
   263  			}
   264  			_ = writer.Close()
   265  		}()
   266  
   267  		patch, err = git.CutDiffAroundLine(reader, int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines)
   268  		if err != nil {
   269  			log.Error("Error whilst generating patch: %v", err)
   270  			return nil, err
   271  		}
   272  	}
   273  	return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
   274  		Type:        issues_model.CommentTypeCode,
   275  		Doer:        doer,
   276  		Repo:        repo,
   277  		Issue:       issue,
   278  		Content:     content,
   279  		LineNum:     line,
   280  		TreePath:    treePath,
   281  		CommitSHA:   commitID,
   282  		ReviewID:    reviewID,
   283  		Patch:       patch,
   284  		Invalidated: invalidated,
   285  		Attachments: attachments,
   286  	})
   287  }
   288  
   289  // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
   290  func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, reviewType issues_model.ReviewType, content, commitID string, attachmentUUIDs []string) (*issues_model.Review, *issues_model.Comment, error) {
   291  	if err := issue.LoadPullRequest(ctx); err != nil {
   292  		return nil, nil, err
   293  	}
   294  
   295  	pr := issue.PullRequest
   296  	var stale bool
   297  	if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject {
   298  		stale = false
   299  	} else {
   300  		if issue.IsClosed {
   301  			return nil, nil, ErrSubmitReviewOnClosedPR
   302  		}
   303  
   304  		headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName())
   305  		if err != nil {
   306  			return nil, nil, err
   307  		}
   308  
   309  		if headCommitID == commitID {
   310  			stale = false
   311  		} else {
   312  			stale, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
   313  			if err != nil {
   314  				return nil, nil, err
   315  			}
   316  		}
   317  	}
   318  
   319  	review, comm, err := issues_model.SubmitReview(ctx, doer, issue, reviewType, content, commitID, stale, attachmentUUIDs)
   320  	if err != nil {
   321  		return nil, nil, err
   322  	}
   323  
   324  	mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comm.Content)
   325  	if err != nil {
   326  		return nil, nil, err
   327  	}
   328  
   329  	notify_service.PullRequestReview(ctx, pr, review, comm, mentions)
   330  
   331  	for _, lines := range review.CodeComments {
   332  		for _, comments := range lines {
   333  			for _, codeComment := range comments {
   334  				mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, codeComment.Content)
   335  				if err != nil {
   336  					return nil, nil, err
   337  				}
   338  				notify_service.PullRequestCodeComment(ctx, pr, codeComment, mentions)
   339  			}
   340  		}
   341  	}
   342  
   343  	return review, comm, nil
   344  }
   345  
   346  // DismissApprovalReviews dismiss all approval reviews because of new commits
   347  func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest) error {
   348  	reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
   349  		ListOptions: db.ListOptionsAll,
   350  		IssueID:     pull.IssueID,
   351  		Types:       []issues_model.ReviewType{issues_model.ReviewTypeApprove},
   352  		Dismissed:   optional.Some(false),
   353  	})
   354  	if err != nil {
   355  		return err
   356  	}
   357  
   358  	if err := reviews.LoadIssues(ctx); err != nil {
   359  		return err
   360  	}
   361  
   362  	return db.WithTx(ctx, func(ctx context.Context) error {
   363  		for _, review := range reviews {
   364  			if err := issues_model.DismissReview(ctx, review, true); err != nil {
   365  				return err
   366  			}
   367  
   368  			comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
   369  				Doer:     doer,
   370  				Content:  "New commits pushed, approval review dismissed automatically according to repository settings",
   371  				Type:     issues_model.CommentTypeDismissReview,
   372  				ReviewID: review.ID,
   373  				Issue:    review.Issue,
   374  				Repo:     review.Issue.Repo,
   375  			})
   376  			if err != nil {
   377  				return err
   378  			}
   379  
   380  			comment.Review = review
   381  			comment.Poster = doer
   382  			comment.Issue = review.Issue
   383  
   384  			notify_service.PullReviewDismiss(ctx, doer, review, comment)
   385  		}
   386  		return nil
   387  	})
   388  }
   389  
   390  // DismissReview dismissing stale review by repo admin
   391  func DismissReview(ctx context.Context, reviewID, repoID int64, message string, doer *user_model.User, isDismiss, dismissPriors bool) (comment *issues_model.Comment, err error) {
   392  	review, err := issues_model.GetReviewByID(ctx, reviewID)
   393  	if err != nil {
   394  		return nil, err
   395  	}
   396  
   397  	if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject {
   398  		return nil, fmt.Errorf("not need to dismiss this review because it's type is not Approve or change request")
   399  	}
   400  
   401  	// load data for notify
   402  	if err := review.LoadAttributes(ctx); err != nil {
   403  		return nil, err
   404  	}
   405  
   406  	// Check if the review's repoID is the one we're currently expecting.
   407  	if review.Issue.RepoID != repoID {
   408  		return nil, fmt.Errorf("reviews's repository is not the same as the one we expect")
   409  	}
   410  
   411  	issue := review.Issue
   412  
   413  	if issue.IsClosed {
   414  		return nil, ErrDismissRequestOnClosedPR{}
   415  	}
   416  
   417  	if issue.IsPull {
   418  		if err := issue.LoadPullRequest(ctx); err != nil {
   419  			return nil, err
   420  		}
   421  		if issue.PullRequest.HasMerged {
   422  			return nil, ErrDismissRequestOnClosedPR{}
   423  		}
   424  	}
   425  
   426  	if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil {
   427  		return nil, err
   428  	}
   429  
   430  	if dismissPriors {
   431  		reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
   432  			IssueID:    review.IssueID,
   433  			ReviewerID: review.ReviewerID,
   434  			Dismissed:  optional.Some(false),
   435  		})
   436  		if err != nil {
   437  			return nil, err
   438  		}
   439  		for _, oldReview := range reviews {
   440  			if err = issues_model.DismissReview(ctx, oldReview, true); err != nil {
   441  				return nil, err
   442  			}
   443  		}
   444  	}
   445  
   446  	if !isDismiss {
   447  		return nil, nil
   448  	}
   449  
   450  	if err := review.Issue.LoadAttributes(ctx); err != nil {
   451  		return nil, err
   452  	}
   453  
   454  	comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
   455  		Doer:     doer,
   456  		Content:  message,
   457  		Type:     issues_model.CommentTypeDismissReview,
   458  		ReviewID: review.ID,
   459  		Issue:    review.Issue,
   460  		Repo:     review.Issue.Repo,
   461  	})
   462  	if err != nil {
   463  		return nil, err
   464  	}
   465  
   466  	comment.Review = review
   467  	comment.Poster = doer
   468  	comment.Issue = review.Issue
   469  
   470  	notify_service.PullReviewDismiss(ctx, doer, review, comment)
   471  
   472  	return comment, nil
   473  }