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 }