1// Copyright 2019 The Gitea Authors. 2// All rights reserved. 3// Use of this source code is governed by a MIT-style 4// license that can be found in the LICENSE file. 5 6package pull 7 8import ( 9 "fmt" 10 "io" 11 "regexp" 12 "strings" 13 14 "code.gitea.io/gitea/models" 15 "code.gitea.io/gitea/models/db" 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/notification" 21 "code.gitea.io/gitea/modules/setting" 22) 23 24// CreateCodeComment creates a comment on the code line 25func CreateCodeComment(doer *user_model.User, gitRepo *git.Repository, issue *models.Issue, line int64, content, treePath string, isReview bool, replyReviewID int64, latestCommitID string) (*models.Comment, error) { 26 var ( 27 existsReview bool 28 err error 29 ) 30 31 // CreateCodeComment() is used for: 32 // - Single comments 33 // - Comments that are part of a review 34 // - Comments that reply to an existing review 35 36 if !isReview && replyReviewID != 0 { 37 // It's not part of a review; maybe a reply to a review comment or a single comment. 38 // Check if there are reviews for that line already; if there are, this is a reply 39 if existsReview, err = models.ReviewExists(issue, treePath, line); err != nil { 40 return nil, err 41 } 42 } 43 44 // Comments that are replies don't require a review header to show up in the issue view 45 if !isReview && existsReview { 46 if err = issue.LoadRepo(); err != nil { 47 return nil, err 48 } 49 50 comment, err := createCodeComment( 51 doer, 52 issue.Repo, 53 issue, 54 content, 55 treePath, 56 line, 57 replyReviewID, 58 ) 59 if err != nil { 60 return nil, err 61 } 62 63 mentions, err := issue.FindAndUpdateIssueMentions(db.DefaultContext, doer, comment.Content) 64 if err != nil { 65 return nil, err 66 } 67 68 notification.NotifyCreateIssueComment(doer, issue.Repo, issue, comment, mentions) 69 70 return comment, nil 71 } 72 73 review, err := models.GetCurrentReview(doer, issue) 74 if err != nil { 75 if !models.IsErrReviewNotExist(err) { 76 return nil, err 77 } 78 79 if review, err = models.CreateReview(models.CreateReviewOptions{ 80 Type: models.ReviewTypePending, 81 Reviewer: doer, 82 Issue: issue, 83 Official: false, 84 CommitID: latestCommitID, 85 }); err != nil { 86 return nil, err 87 } 88 } 89 90 comment, err := createCodeComment( 91 doer, 92 issue.Repo, 93 issue, 94 content, 95 treePath, 96 line, 97 review.ID, 98 ) 99 if err != nil { 100 return nil, err 101 } 102 103 if !isReview && !existsReview { 104 // Submit the review we've just created so the comment shows up in the issue view 105 if _, _, err = SubmitReview(doer, gitRepo, issue, models.ReviewTypeComment, "", latestCommitID, nil); err != nil { 106 return nil, err 107 } 108 } 109 110 // NOTICE: if it's a pending review the notifications will not be fired until user submit review. 111 112 return comment, nil 113} 114 115var notEnoughLines = regexp.MustCompile(`exit status 128 - fatal: file .* has only \d+ lines?`) 116 117// createCodeComment creates a plain code comment at the specified line / path 118func createCodeComment(doer *user_model.User, repo *repo_model.Repository, issue *models.Issue, content, treePath string, line, reviewID int64) (*models.Comment, error) { 119 var commitID, patch string 120 if err := issue.LoadPullRequest(); err != nil { 121 return nil, fmt.Errorf("GetPullRequestByIssueID: %v", err) 122 } 123 pr := issue.PullRequest 124 if err := pr.LoadBaseRepo(); err != nil { 125 return nil, fmt.Errorf("LoadHeadRepo: %v", err) 126 } 127 gitRepo, err := git.OpenRepository(pr.BaseRepo.RepoPath()) 128 if err != nil { 129 return nil, fmt.Errorf("OpenRepository: %v", err) 130 } 131 defer gitRepo.Close() 132 133 invalidated := false 134 head := pr.GetGitRefName() 135 if line > 0 { 136 if reviewID != 0 { 137 first, err := models.FindComments(&models.FindCommentsOptions{ 138 ReviewID: reviewID, 139 Line: line, 140 TreePath: treePath, 141 Type: models.CommentTypeCode, 142 ListOptions: db.ListOptions{ 143 PageSize: 1, 144 Page: 1, 145 }, 146 }) 147 if err == nil && len(first) > 0 { 148 commitID = first[0].CommitSHA 149 invalidated = first[0].Invalidated 150 patch = first[0].Patch 151 } else if err != nil && !models.IsErrCommentNotExist(err) { 152 return nil, fmt.Errorf("Find first comment for %d line %d path %s. Error: %v", reviewID, line, treePath, err) 153 } else { 154 review, err := models.GetReviewByID(reviewID) 155 if err == nil && len(review.CommitID) > 0 { 156 head = review.CommitID 157 } else if err != nil && !models.IsErrReviewNotExist(err) { 158 return nil, fmt.Errorf("GetReviewByID %d. Error: %v", reviewID, err) 159 } 160 } 161 } 162 163 if len(commitID) == 0 { 164 // FIXME validate treePath 165 // Get latest commit referencing the commented line 166 // No need for get commit for base branch changes 167 commit, err := gitRepo.LineBlame(head, gitRepo.Path, treePath, uint(line)) 168 if err == nil { 169 commitID = commit.ID.String() 170 } else if !(strings.Contains(err.Error(), "exit status 128 - fatal: no such path") || notEnoughLines.MatchString(err.Error())) { 171 return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %v", pr.GetGitRefName(), gitRepo.Path, treePath, line, err) 172 } 173 } 174 } 175 176 // Only fetch diff if comment is review comment 177 if len(patch) == 0 && reviewID != 0 { 178 headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) 179 if err != nil { 180 return nil, fmt.Errorf("GetRefCommitID[%s]: %v", pr.GetGitRefName(), err) 181 } 182 if len(commitID) == 0 { 183 commitID = headCommitID 184 } 185 reader, writer := io.Pipe() 186 defer func() { 187 _ = reader.Close() 188 _ = writer.Close() 189 }() 190 go func() { 191 if err := git.GetRepoRawDiffForFile(gitRepo, pr.MergeBase, headCommitID, git.RawDiffNormal, treePath, writer); err != nil { 192 _ = writer.CloseWithError(fmt.Errorf("GetRawDiffForLine[%s, %s, %s, %s]: %v", gitRepo.Path, pr.MergeBase, headCommitID, treePath, err)) 193 return 194 } 195 _ = writer.Close() 196 }() 197 198 patch, err = git.CutDiffAroundLine(reader, int64((&models.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines) 199 if err != nil { 200 log.Error("Error whilst generating patch: %v", err) 201 return nil, err 202 } 203 } 204 return models.CreateComment(&models.CreateCommentOptions{ 205 Type: models.CommentTypeCode, 206 Doer: doer, 207 Repo: repo, 208 Issue: issue, 209 Content: content, 210 LineNum: line, 211 TreePath: treePath, 212 CommitSHA: commitID, 213 ReviewID: reviewID, 214 Patch: patch, 215 Invalidated: invalidated, 216 }) 217} 218 219// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist 220func SubmitReview(doer *user_model.User, gitRepo *git.Repository, issue *models.Issue, reviewType models.ReviewType, content, commitID string, attachmentUUIDs []string) (*models.Review, *models.Comment, error) { 221 pr, err := issue.GetPullRequest() 222 if err != nil { 223 return nil, nil, err 224 } 225 226 var stale bool 227 if reviewType != models.ReviewTypeApprove && reviewType != models.ReviewTypeReject { 228 stale = false 229 } else { 230 headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) 231 if err != nil { 232 return nil, nil, err 233 } 234 235 if headCommitID == commitID { 236 stale = false 237 } else { 238 stale, err = checkIfPRContentChanged(pr, commitID, headCommitID) 239 if err != nil { 240 return nil, nil, err 241 } 242 } 243 } 244 245 review, comm, err := models.SubmitReview(doer, issue, reviewType, content, commitID, stale, attachmentUUIDs) 246 if err != nil { 247 return nil, nil, err 248 } 249 250 ctx := db.DefaultContext 251 mentions, err := issue.FindAndUpdateIssueMentions(ctx, doer, comm.Content) 252 if err != nil { 253 return nil, nil, err 254 } 255 256 notification.NotifyPullRequestReview(pr, review, comm, mentions) 257 258 for _, lines := range review.CodeComments { 259 for _, comments := range lines { 260 for _, codeComment := range comments { 261 mentions, err := issue.FindAndUpdateIssueMentions(ctx, doer, codeComment.Content) 262 if err != nil { 263 return nil, nil, err 264 } 265 notification.NotifyPullRequestCodeComment(pr, codeComment, mentions) 266 } 267 } 268 } 269 270 return review, comm, nil 271} 272 273// DismissReview dismissing stale review by repo admin 274func DismissReview(reviewID int64, message string, doer *user_model.User, isDismiss bool) (comment *models.Comment, err error) { 275 review, err := models.GetReviewByID(reviewID) 276 if err != nil { 277 return 278 } 279 280 if review.Type != models.ReviewTypeApprove && review.Type != models.ReviewTypeReject { 281 return nil, fmt.Errorf("not need to dismiss this review because it's type is not Approve or change request") 282 } 283 284 if err = models.DismissReview(review, isDismiss); err != nil { 285 return 286 } 287 288 if !isDismiss { 289 return nil, nil 290 } 291 292 // load data for notify 293 if err = review.LoadAttributes(); err != nil { 294 return 295 } 296 if err = review.Issue.LoadPullRequest(); err != nil { 297 return 298 } 299 if err = review.Issue.LoadAttributes(); err != nil { 300 return 301 } 302 303 comment, err = models.CreateComment(&models.CreateCommentOptions{ 304 Doer: doer, 305 Content: message, 306 Type: models.CommentTypeDismissReview, 307 ReviewID: review.ID, 308 Issue: review.Issue, 309 Repo: review.Issue.Repo, 310 }) 311 if err != nil { 312 return 313 } 314 315 comment.Review = review 316 comment.Poster = doer 317 comment.Issue = review.Issue 318 319 notification.NotifyPullRevieweDismiss(doer, review, comment) 320 321 return 322} 323