gitea
Зеркало из https://github.com/go-gitea/gitea
1// Copyright 2019 The Gitea Authors.
2// All rights reserved.
3// SPDX-License-Identifier: MIT
4
5package pull
6
7import (
8"context"
9"errors"
10"fmt"
11"io"
12"regexp"
13"strings"
14
15"code.gitea.io/gitea/models/db"
16issues_model "code.gitea.io/gitea/models/issues"
17repo_model "code.gitea.io/gitea/models/repo"
18user_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"
25notify_service "code.gitea.io/gitea/services/notify"
26)
27
28var 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.
31type ErrDismissRequestOnClosedPR struct{}
32
33// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR.
34func IsErrDismissRequestOnClosedPR(err error) bool {
35_, ok := err.(ErrDismissRequestOnClosedPR)
36return ok
37}
38
39func (err ErrDismissRequestOnClosedPR) Error() string {
40return "can't dismiss a review associated to a closed or merged PR"
41}
42
43func (err ErrDismissRequestOnClosedPR) Unwrap() error {
44return 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.
48var 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.
52func checkInvalidation(ctx context.Context, c *issues_model.Comment, repo *git.Repository, branch string) error {
53// FIXME differentiate between previous and proposed line
54commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine()))
55if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
56c.Invalidated = true
57return issues_model.UpdateCommentInvalidate(ctx, c)
58}
59if err != nil {
60return err
61}
62if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
63c.Invalidated = true
64return issues_model.UpdateCommentInvalidate(ctx, c)
65}
66return nil
67}
68
69// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
70func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *git.Repository, branch string) error {
71if len(prs) == 0 {
72return nil
73}
74issueIDs := prs.GetIssueIDs()
75
76codeComments, err := db.Find[issues_model.Comment](ctx, issues_model.FindCommentsOptions{
77ListOptions: db.ListOptionsAll,
78Type: issues_model.CommentTypeCode,
79Invalidated: optional.Some(false),
80IssueIDs: issueIDs,
81})
82if err != nil {
83return fmt.Errorf("find code comments: %v", err)
84}
85for _, comment := range codeComments {
86if err := checkInvalidation(ctx, comment, repo, branch); err != nil {
87return err
88}
89}
90return nil
91}
92
93// CreateCodeComment creates a comment on the code line
94func 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) {
95var (
96existsReview bool
97err 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
105if !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
108if existsReview, err = issues_model.ReviewExists(ctx, issue, treePath, line); err != nil {
109return nil, err
110}
111}
112
113// Comments that are replies don't require a review header to show up in the issue view
114if !pendingReview && existsReview {
115if err = issue.LoadRepo(ctx); err != nil {
116return nil, err
117}
118
119comment, err := createCodeComment(ctx,
120doer,
121issue.Repo,
122issue,
123content,
124treePath,
125line,
126replyReviewID,
127attachments,
128)
129if err != nil {
130return nil, err
131}
132
133mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content)
134if err != nil {
135return nil, err
136}
137
138notify_service.CreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions)
139
140return comment, nil
141}
142
143review, err := issues_model.GetCurrentReview(ctx, doer, issue)
144if err != nil {
145if !issues_model.IsErrReviewNotExist(err) {
146return nil, err
147}
148
149if review, err = issues_model.CreateReview(ctx, issues_model.CreateReviewOptions{
150Type: issues_model.ReviewTypePending,
151Reviewer: doer,
152Issue: issue,
153Official: false,
154CommitID: latestCommitID,
155}); err != nil {
156return nil, err
157}
158}
159
160comment, err := createCodeComment(ctx,
161doer,
162issue.Repo,
163issue,
164content,
165treePath,
166line,
167review.ID,
168attachments,
169)
170if err != nil {
171return nil, err
172}
173
174if !pendingReview && !existsReview {
175// Submit the review we've just created so the comment shows up in the issue view
176if _, _, err = SubmitReview(ctx, doer, gitRepo, issue, issues_model.ReviewTypeComment, "", latestCommitID, nil); err != nil {
177return nil, err
178}
179}
180
181// NOTICE: if it's a pending review the notifications will not be fired until user submit review.
182
183return comment, nil
184}
185
186// createCodeComment creates a plain code comment at the specified line / path
187func 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) {
188var commitID, patch string
189if err := issue.LoadPullRequest(ctx); err != nil {
190return nil, fmt.Errorf("LoadPullRequest: %w", err)
191}
192pr := issue.PullRequest
193if err := pr.LoadBaseRepo(ctx); err != nil {
194return nil, fmt.Errorf("LoadBaseRepo: %w", err)
195}
196gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
197if err != nil {
198return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err)
199}
200defer closer.Close()
201
202invalidated := false
203head := pr.GetGitRefName()
204if line > 0 {
205if reviewID != 0 {
206first, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{
207ReviewID: reviewID,
208Line: line,
209TreePath: treePath,
210Type: issues_model.CommentTypeCode,
211ListOptions: db.ListOptions{
212PageSize: 1,
213Page: 1,
214},
215})
216if err == nil && len(first) > 0 {
217commitID = first[0].CommitSHA
218invalidated = first[0].Invalidated
219patch = first[0].Patch
220} else if err != nil && !issues_model.IsErrCommentNotExist(err) {
221return nil, fmt.Errorf("Find first comment for %d line %d path %s. Error: %w", reviewID, line, treePath, err)
222} else {
223review, err := issues_model.GetReviewByID(ctx, reviewID)
224if err == nil && len(review.CommitID) > 0 {
225head = review.CommitID
226} else if err != nil && !issues_model.IsErrReviewNotExist(err) {
227return nil, fmt.Errorf("GetReviewByID %d. Error: %w", reviewID, err)
228}
229}
230}
231
232if 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
236commit, err := gitRepo.LineBlame(head, gitRepo.Path, treePath, uint(line))
237if err == nil {
238commitID = commit.ID.String()
239} else if !(strings.Contains(err.Error(), "exit status 128 - fatal: no such path") || notEnoughLines.MatchString(err.Error())) {
240return 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
246if len(patch) == 0 && reviewID != 0 {
247headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName())
248if err != nil {
249return nil, fmt.Errorf("GetRefCommitID[%s]: %w", pr.GetGitRefName(), err)
250}
251if len(commitID) == 0 {
252commitID = headCommitID
253}
254reader, writer := io.Pipe()
255defer func() {
256_ = reader.Close()
257_ = writer.Close()
258}()
259go func() {
260if 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))
262return
263}
264_ = writer.Close()
265}()
266
267patch, err = git.CutDiffAroundLine(reader, int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines)
268if err != nil {
269log.Error("Error whilst generating patch: %v", err)
270return nil, err
271}
272}
273return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
274Type: issues_model.CommentTypeCode,
275Doer: doer,
276Repo: repo,
277Issue: issue,
278Content: content,
279LineNum: line,
280TreePath: treePath,
281CommitSHA: commitID,
282ReviewID: reviewID,
283Patch: patch,
284Invalidated: invalidated,
285Attachments: 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
290func 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) {
291if err := issue.LoadPullRequest(ctx); err != nil {
292return nil, nil, err
293}
294
295pr := issue.PullRequest
296var stale bool
297if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject {
298stale = false
299} else {
300if issue.IsClosed {
301return nil, nil, ErrSubmitReviewOnClosedPR
302}
303
304headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName())
305if err != nil {
306return nil, nil, err
307}
308
309if headCommitID == commitID {
310stale = false
311} else {
312stale, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
313if err != nil {
314return nil, nil, err
315}
316}
317}
318
319review, comm, err := issues_model.SubmitReview(ctx, doer, issue, reviewType, content, commitID, stale, attachmentUUIDs)
320if err != nil {
321return nil, nil, err
322}
323
324mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comm.Content)
325if err != nil {
326return nil, nil, err
327}
328
329notify_service.PullRequestReview(ctx, pr, review, comm, mentions)
330
331for _, lines := range review.CodeComments {
332for _, comments := range lines {
333for _, codeComment := range comments {
334mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, codeComment.Content)
335if err != nil {
336return nil, nil, err
337}
338notify_service.PullRequestCodeComment(ctx, pr, codeComment, mentions)
339}
340}
341}
342
343return review, comm, nil
344}
345
346// DismissApprovalReviews dismiss all approval reviews because of new commits
347func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest) error {
348reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
349ListOptions: db.ListOptionsAll,
350IssueID: pull.IssueID,
351Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
352Dismissed: optional.Some(false),
353})
354if err != nil {
355return err
356}
357
358if err := reviews.LoadIssues(ctx); err != nil {
359return err
360}
361
362return db.WithTx(ctx, func(ctx context.Context) error {
363for _, review := range reviews {
364if err := issues_model.DismissReview(ctx, review, true); err != nil {
365return err
366}
367
368comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
369Doer: doer,
370Content: "New commits pushed, approval review dismissed automatically according to repository settings",
371Type: issues_model.CommentTypeDismissReview,
372ReviewID: review.ID,
373Issue: review.Issue,
374Repo: review.Issue.Repo,
375})
376if err != nil {
377return err
378}
379
380comment.Review = review
381comment.Poster = doer
382comment.Issue = review.Issue
383
384notify_service.PullReviewDismiss(ctx, doer, review, comment)
385}
386return nil
387})
388}
389
390// DismissReview dismissing stale review by repo admin
391func DismissReview(ctx context.Context, reviewID, repoID int64, message string, doer *user_model.User, isDismiss, dismissPriors bool) (comment *issues_model.Comment, err error) {
392review, err := issues_model.GetReviewByID(ctx, reviewID)
393if err != nil {
394return nil, err
395}
396
397if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject {
398return 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
402if err := review.LoadAttributes(ctx); err != nil {
403return nil, err
404}
405
406// Check if the review's repoID is the one we're currently expecting.
407if review.Issue.RepoID != repoID {
408return nil, fmt.Errorf("reviews's repository is not the same as the one we expect")
409}
410
411issue := review.Issue
412
413if issue.IsClosed {
414return nil, ErrDismissRequestOnClosedPR{}
415}
416
417if issue.IsPull {
418if err := issue.LoadPullRequest(ctx); err != nil {
419return nil, err
420}
421if issue.PullRequest.HasMerged {
422return nil, ErrDismissRequestOnClosedPR{}
423}
424}
425
426if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil {
427return nil, err
428}
429
430if dismissPriors {
431reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
432IssueID: review.IssueID,
433ReviewerID: review.ReviewerID,
434Dismissed: optional.Some(false),
435})
436if err != nil {
437return nil, err
438}
439for _, oldReview := range reviews {
440if err = issues_model.DismissReview(ctx, oldReview, true); err != nil {
441return nil, err
442}
443}
444}
445
446if !isDismiss {
447return nil, nil
448}
449
450if err := review.Issue.LoadAttributes(ctx); err != nil {
451return nil, err
452}
453
454comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
455Doer: doer,
456Content: message,
457Type: issues_model.CommentTypeDismissReview,
458ReviewID: review.ID,
459Issue: review.Issue,
460Repo: review.Issue.Repo,
461})
462if err != nil {
463return nil, err
464}
465
466comment.Review = review
467comment.Poster = doer
468comment.Issue = review.Issue
469
470notify_service.PullReviewDismiss(ctx, doer, review, comment)
471
472return comment, nil
473}
474