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