diff options
Diffstat (limited to '')
-rw-r--r-- | services/pull/review.go | 465 |
1 files changed, 465 insertions, 0 deletions
diff --git a/services/pull/review.go b/services/pull/review.go new file mode 100644 index 0000000..927c431 --- /dev/null +++ b/services/pull/review.go @@ -0,0 +1,465 @@ +// Copyright 2019 The Gitea Authors. +// All rights reserved. +// SPDX-License-Identifier: MIT + +package pull + +import ( + "context" + "fmt" + "io" + "regexp" + "strings" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + notify_service "code.gitea.io/gitea/services/notify" +) + +var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`) + +// ErrDismissRequestOnClosedPR represents an error when an user tries to dismiss a review associated to a closed or merged PR. +type ErrDismissRequestOnClosedPR struct{} + +// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR. +func IsErrDismissRequestOnClosedPR(err error) bool { + _, ok := err.(ErrDismissRequestOnClosedPR) + return ok +} + +func (err ErrDismissRequestOnClosedPR) Error() string { + return "can't dismiss a review associated to a closed or merged PR" +} + +func (err ErrDismissRequestOnClosedPR) Unwrap() error { + return util.ErrPermissionDenied +} + +// checkInvalidation checks if the line of code comment got changed by another commit. +// If the line got changed the comment is going to be invalidated. +func checkInvalidation(ctx context.Context, c *issues_model.Comment, repo *git.Repository, branch string) error { + // FIXME differentiate between previous and proposed line + commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine())) + if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) { + c.Invalidated = true + return issues_model.UpdateCommentInvalidate(ctx, c) + } + if err != nil { + return err + } + if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() { + c.Invalidated = true + return issues_model.UpdateCommentInvalidate(ctx, c) + } + return nil +} + +// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change +func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *git.Repository, branch string) error { + if len(prs) == 0 { + return nil + } + issueIDs := prs.GetIssueIDs() + + codeComments, err := db.Find[issues_model.Comment](ctx, issues_model.FindCommentsOptions{ + ListOptions: db.ListOptionsAll, + Type: issues_model.CommentTypeCode, + Invalidated: optional.Some(false), + IssueIDs: issueIDs, + }) + if err != nil { + return fmt.Errorf("find code comments: %v", err) + } + for _, comment := range codeComments { + if err := checkInvalidation(ctx, comment, repo, branch); err != nil { + return err + } + } + return nil +} + +// CreateCodeComment creates a comment on the code line +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) { + var ( + existsReview bool + err error + ) + + // CreateCodeComment() is used for: + // - Single comments + // - Comments that are part of a review + // - Comments that reply to an existing review + + if !pendingReview && replyReviewID != 0 { + // It's not part of a review; maybe a reply to a review comment or a single comment. + // Check if there are reviews for that line already; if there are, this is a reply + if existsReview, err = issues_model.ReviewExists(ctx, issue, treePath, line); err != nil { + return nil, err + } + } + + // Comments that are replies don't require a review header to show up in the issue view + if !pendingReview && existsReview { + if err = issue.LoadRepo(ctx); err != nil { + return nil, err + } + + comment, err := CreateCodeCommentKnownReviewID(ctx, + doer, + issue.Repo, + issue, + content, + treePath, + line, + replyReviewID, + attachments, + ) + if err != nil { + return nil, err + } + + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content) + if err != nil { + return nil, err + } + + notify_service.CreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions) + + return comment, nil + } + + review, err := issues_model.GetCurrentReview(ctx, doer, issue) + if err != nil { + if !issues_model.IsErrReviewNotExist(err) { + return nil, err + } + + if review, err = issues_model.CreateReview(ctx, issues_model.CreateReviewOptions{ + Type: issues_model.ReviewTypePending, + Reviewer: doer, + Issue: issue, + Official: false, + CommitID: latestCommitID, + }); err != nil { + return nil, err + } + } + + comment, err := CreateCodeCommentKnownReviewID(ctx, + doer, + issue.Repo, + issue, + content, + treePath, + line, + review.ID, + attachments, + ) + if err != nil { + return nil, err + } + + if !pendingReview && !existsReview { + // Submit the review we've just created so the comment shows up in the issue view + if _, _, err = SubmitReview(ctx, doer, gitRepo, issue, issues_model.ReviewTypeComment, "", latestCommitID, nil); err != nil { + return nil, err + } + } + + // NOTICE: if it's a pending review the notifications will not be fired until user submit review. + + return comment, nil +} + +// CreateCodeCommentKnownReviewID creates a plain code comment at the specified line / path +func CreateCodeCommentKnownReviewID(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) { + var commitID, patch string + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, fmt.Errorf("LoadPullRequest: %w", err) + } + pr := issue.PullRequest + if err := pr.LoadBaseRepo(ctx); err != nil { + return nil, fmt.Errorf("LoadBaseRepo: %w", err) + } + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) + if err != nil { + return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err) + } + defer closer.Close() + + invalidated := false + head := pr.GetGitRefName() + if line > 0 { + if reviewID != 0 { + first, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{ + ReviewID: reviewID, + Line: line, + TreePath: treePath, + Type: issues_model.CommentTypeCode, + ListOptions: db.ListOptions{ + PageSize: 1, + Page: 1, + }, + }) + if err == nil && len(first) > 0 { + commitID = first[0].CommitSHA + invalidated = first[0].Invalidated + patch = first[0].Patch + } else if err != nil && !issues_model.IsErrCommentNotExist(err) { + return nil, fmt.Errorf("Find first comment for %d line %d path %s. Error: %w", reviewID, line, treePath, err) + } else { + review, err := issues_model.GetReviewByID(ctx, reviewID) + if err == nil && len(review.CommitID) > 0 { + head = review.CommitID + } else if err != nil && !issues_model.IsErrReviewNotExist(err) { + return nil, fmt.Errorf("GetReviewByID %d. Error: %w", reviewID, err) + } + } + } + + if len(commitID) == 0 { + // FIXME validate treePath + // Get latest commit referencing the commented line + // No need for get commit for base branch changes + commit, err := gitRepo.LineBlame(head, gitRepo.Path, treePath, uint(line)) + if err == nil { + commitID = commit.ID.String() + } else if !(strings.Contains(err.Error(), "exit status 128 - fatal: no such path") || notEnoughLines.MatchString(err.Error())) { + return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitRefName(), gitRepo.Path, treePath, line, err) + } + } + } + + // Only fetch diff if comment is review comment + if len(patch) == 0 && reviewID != 0 { + headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + return nil, fmt.Errorf("GetRefCommitID[%s]: %w", pr.GetGitRefName(), err) + } + if len(commitID) == 0 { + commitID = headCommitID + } + reader, writer := io.Pipe() + defer func() { + _ = reader.Close() + _ = writer.Close() + }() + go func() { + if err := git.GetRepoRawDiffForFile(gitRepo, pr.MergeBase, headCommitID, git.RawDiffNormal, treePath, writer); err != nil { + _ = writer.CloseWithError(fmt.Errorf("GetRawDiffForLine[%s, %s, %s, %s]: %w", gitRepo.Path, pr.MergeBase, headCommitID, treePath, err)) + return + } + _ = writer.Close() + }() + + patch, err = git.CutDiffAroundLine(reader, int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines) + if err != nil { + log.Error("Error whilst generating patch: %v", err) + return nil, err + } + } + return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeCode, + Doer: doer, + Repo: repo, + Issue: issue, + Content: content, + LineNum: line, + TreePath: treePath, + CommitSHA: commitID, + ReviewID: reviewID, + Patch: patch, + Invalidated: invalidated, + Attachments: attachments, + }) +} + +// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist +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) { + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, nil, err + } + + pr := issue.PullRequest + var stale bool + if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject { + stale = false + } else { + headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + return nil, nil, err + } + + if headCommitID == commitID { + stale = false + } else { + stale, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID) + if err != nil { + return nil, nil, err + } + } + } + + review, comm, err := issues_model.SubmitReview(ctx, doer, issue, reviewType, content, commitID, stale, attachmentUUIDs) + if err != nil { + return nil, nil, err + } + + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comm.Content) + if err != nil { + return nil, nil, err + } + + notify_service.PullRequestReview(ctx, pr, review, comm, mentions) + + for _, lines := range review.CodeComments { + for _, comments := range lines { + for _, codeComment := range comments { + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, codeComment.Content) + if err != nil { + return nil, nil, err + } + notify_service.PullRequestCodeComment(ctx, pr, codeComment, mentions) + } + } + } + + return review, comm, nil +} + +// DismissApprovalReviews dismiss all approval reviews because of new commits +func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest) error { + reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{ + ListOptions: db.ListOptionsAll, + IssueID: pull.IssueID, + Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove}, + Dismissed: optional.Some(false), + }) + if err != nil { + return err + } + + if err := reviews.LoadIssues(ctx); err != nil { + return err + } + + return db.WithTx(ctx, func(ctx context.Context) error { + for _, review := range reviews { + if err := issues_model.DismissReview(ctx, review, true); err != nil { + return err + } + + comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Doer: doer, + Content: "New commits pushed, approval review dismissed automatically according to repository settings", + Type: issues_model.CommentTypeDismissReview, + ReviewID: review.ID, + Issue: review.Issue, + Repo: review.Issue.Repo, + }) + if err != nil { + return err + } + + comment.Review = review + comment.Poster = doer + comment.Issue = review.Issue + + notify_service.PullReviewDismiss(ctx, doer, review, comment) + } + return nil + }) +} + +// DismissReview dismissing stale review by repo admin +func DismissReview(ctx context.Context, reviewID, repoID int64, message string, doer *user_model.User, isDismiss, dismissPriors bool) (comment *issues_model.Comment, err error) { + review, err := issues_model.GetReviewByID(ctx, reviewID) + if err != nil { + return nil, err + } + + if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject { + return nil, fmt.Errorf("not need to dismiss this review because it's type is not Approve or change request") + } + + // load data for notify + if err := review.LoadAttributes(ctx); err != nil { + return nil, err + } + + // Check if the review's repoID is the one we're currently expecting. + if review.Issue.RepoID != repoID { + return nil, fmt.Errorf("reviews's repository is not the same as the one we expect") + } + + issue := review.Issue + + if issue.IsClosed { + return nil, ErrDismissRequestOnClosedPR{} + } + + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + return nil, err + } + if issue.PullRequest.HasMerged { + return nil, ErrDismissRequestOnClosedPR{} + } + } + + if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil { + return nil, err + } + + if dismissPriors { + reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{ + IssueID: review.IssueID, + ReviewerID: review.ReviewerID, + Dismissed: optional.Some(false), + }) + if err != nil { + return nil, err + } + for _, oldReview := range reviews { + if err = issues_model.DismissReview(ctx, oldReview, true); err != nil { + return nil, err + } + } + } + + if !isDismiss { + return nil, nil + } + + if err := review.Issue.LoadAttributes(ctx); err != nil { + return nil, err + } + + comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Doer: doer, + Content: message, + Type: issues_model.CommentTypeDismissReview, + ReviewID: review.ID, + Issue: review.Issue, + Repo: review.Issue.Repo, + }) + if err != nil { + return nil, err + } + + comment.Review = review + comment.Poster = doer + comment.Issue = review.Issue + + notify_service.PullReviewDismiss(ctx, doer, review, comment) + + return comment, nil +} |