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 }