code.gitea.io/gitea@v1.22.3/routers/web/repo/pull_review.go (about)

     1  // Copyright 2018 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package repo
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  	"net/http"
    10  
    11  	issues_model "code.gitea.io/gitea/models/issues"
    12  	pull_model "code.gitea.io/gitea/models/pull"
    13  	user_model "code.gitea.io/gitea/models/user"
    14  	"code.gitea.io/gitea/modules/base"
    15  	"code.gitea.io/gitea/modules/json"
    16  	"code.gitea.io/gitea/modules/log"
    17  	"code.gitea.io/gitea/modules/setting"
    18  	"code.gitea.io/gitea/modules/web"
    19  	"code.gitea.io/gitea/services/context"
    20  	"code.gitea.io/gitea/services/context/upload"
    21  	"code.gitea.io/gitea/services/forms"
    22  	pull_service "code.gitea.io/gitea/services/pull"
    23  	user_service "code.gitea.io/gitea/services/user"
    24  )
    25  
    26  const (
    27  	tplDiffConversation     base.TplName = "repo/diff/conversation"
    28  	tplConversationOutdated base.TplName = "repo/diff/conversation_outdated"
    29  	tplTimelineConversation base.TplName = "repo/issue/view_content/conversation"
    30  	tplNewComment           base.TplName = "repo/diff/new_comment"
    31  )
    32  
    33  // RenderNewCodeCommentForm will render the form for creating a new review comment
    34  func RenderNewCodeCommentForm(ctx *context.Context) {
    35  	issue := GetActionIssue(ctx)
    36  	if ctx.Written() {
    37  		return
    38  	}
    39  	if !issue.IsPull {
    40  		return
    41  	}
    42  	currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue)
    43  	if err != nil && !issues_model.IsErrReviewNotExist(err) {
    44  		ctx.ServerError("GetCurrentReview", err)
    45  		return
    46  	}
    47  	ctx.Data["PageIsPullFiles"] = true
    48  	ctx.Data["Issue"] = issue
    49  	ctx.Data["CurrentReview"] = currentReview
    50  	pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(issue.PullRequest.GetGitRefName())
    51  	if err != nil {
    52  		ctx.ServerError("GetRefCommitID", err)
    53  		return
    54  	}
    55  	ctx.Data["AfterCommitID"] = pullHeadCommitID
    56  	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
    57  	upload.AddUploadContext(ctx, "comment")
    58  	ctx.HTML(http.StatusOK, tplNewComment)
    59  }
    60  
    61  // CreateCodeComment will create a code comment including an pending review if required
    62  func CreateCodeComment(ctx *context.Context) {
    63  	form := web.GetForm(ctx).(*forms.CodeCommentForm)
    64  	issue := GetActionIssue(ctx)
    65  	if ctx.Written() {
    66  		return
    67  	}
    68  	if !issue.IsPull {
    69  		return
    70  	}
    71  
    72  	if ctx.HasError() {
    73  		ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
    74  		ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index))
    75  		return
    76  	}
    77  
    78  	signedLine := form.Line
    79  	if form.Side == "previous" {
    80  		signedLine *= -1
    81  	}
    82  
    83  	var attachments []string
    84  	if setting.Attachment.Enabled {
    85  		attachments = form.Files
    86  	}
    87  
    88  	comment, err := pull_service.CreateCodeComment(ctx,
    89  		ctx.Doer,
    90  		ctx.Repo.GitRepo,
    91  		issue,
    92  		signedLine,
    93  		form.Content,
    94  		form.TreePath,
    95  		!form.SingleReview,
    96  		form.Reply,
    97  		form.LatestCommitID,
    98  		attachments,
    99  	)
   100  	if err != nil {
   101  		ctx.ServerError("CreateCodeComment", err)
   102  		return
   103  	}
   104  
   105  	if comment == nil {
   106  		log.Trace("Comment not created: %-v #%d[%d]", ctx.Repo.Repository, issue.Index, issue.ID)
   107  		ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index))
   108  		return
   109  	}
   110  
   111  	log.Trace("Comment created: %-v #%d[%d] Comment[%d]", ctx.Repo.Repository, issue.Index, issue.ID, comment.ID)
   112  
   113  	renderConversation(ctx, comment, form.Origin)
   114  }
   115  
   116  // UpdateResolveConversation add or remove an Conversation resolved mark
   117  func UpdateResolveConversation(ctx *context.Context) {
   118  	origin := ctx.FormString("origin")
   119  	action := ctx.FormString("action")
   120  	commentID := ctx.FormInt64("comment_id")
   121  
   122  	comment, err := issues_model.GetCommentByID(ctx, commentID)
   123  	if err != nil {
   124  		ctx.ServerError("GetIssueByID", err)
   125  		return
   126  	}
   127  
   128  	if err = comment.LoadIssue(ctx); err != nil {
   129  		ctx.ServerError("comment.LoadIssue", err)
   130  		return
   131  	}
   132  
   133  	if comment.Issue.RepoID != ctx.Repo.Repository.ID {
   134  		ctx.NotFound("comment's repoID is incorrect", errors.New("comment's repoID is incorrect"))
   135  		return
   136  	}
   137  
   138  	var permResult bool
   139  	if permResult, err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil {
   140  		ctx.ServerError("CanMarkConversation", err)
   141  		return
   142  	}
   143  	if !permResult {
   144  		ctx.Error(http.StatusForbidden)
   145  		return
   146  	}
   147  
   148  	if !comment.Issue.IsPull {
   149  		ctx.Error(http.StatusBadRequest)
   150  		return
   151  	}
   152  
   153  	if action == "Resolve" || action == "UnResolve" {
   154  		err = issues_model.MarkConversation(ctx, comment, ctx.Doer, action == "Resolve")
   155  		if err != nil {
   156  			ctx.ServerError("MarkConversation", err)
   157  			return
   158  		}
   159  	} else {
   160  		ctx.Error(http.StatusBadRequest)
   161  		return
   162  	}
   163  
   164  	renderConversation(ctx, comment, origin)
   165  }
   166  
   167  func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) {
   168  	ctx.Data["PageIsPullFiles"] = origin == "diff"
   169  
   170  	showOutdatedComments := origin == "timeline" || ctx.Data["ShowOutdatedComments"].(bool)
   171  	comments, err := issues_model.FetchCodeCommentsByLine(ctx, comment.Issue, ctx.Doer, comment.TreePath, comment.Line, showOutdatedComments)
   172  	if err != nil {
   173  		ctx.ServerError("FetchCodeCommentsByLine", err)
   174  		return
   175  	}
   176  	if len(comments) == 0 {
   177  		// if the comments are empty (deleted, outdated, etc), it's better to tell the users that it is outdated
   178  		ctx.HTML(http.StatusOK, tplConversationOutdated)
   179  		return
   180  	}
   181  
   182  	if err := comments.LoadAttachments(ctx); err != nil {
   183  		ctx.ServerError("LoadAttachments", err)
   184  		return
   185  	}
   186  
   187  	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
   188  	upload.AddUploadContext(ctx, "comment")
   189  
   190  	ctx.Data["comments"] = comments
   191  	if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil {
   192  		ctx.ServerError("CanMarkConversation", err)
   193  		return
   194  	}
   195  	ctx.Data["Issue"] = comment.Issue
   196  	if err = comment.Issue.LoadPullRequest(ctx); err != nil {
   197  		ctx.ServerError("comment.Issue.LoadPullRequest", err)
   198  		return
   199  	}
   200  	pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(comment.Issue.PullRequest.GetGitRefName())
   201  	if err != nil {
   202  		ctx.ServerError("GetRefCommitID", err)
   203  		return
   204  	}
   205  	ctx.Data["AfterCommitID"] = pullHeadCommitID
   206  	ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
   207  		return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
   208  	}
   209  
   210  	if origin == "diff" {
   211  		ctx.HTML(http.StatusOK, tplDiffConversation)
   212  	} else if origin == "timeline" {
   213  		ctx.HTML(http.StatusOK, tplTimelineConversation)
   214  	} else {
   215  		ctx.Error(http.StatusBadRequest, "Unknown origin: "+origin)
   216  	}
   217  }
   218  
   219  // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
   220  func SubmitReview(ctx *context.Context) {
   221  	form := web.GetForm(ctx).(*forms.SubmitReviewForm)
   222  	issue := GetActionIssue(ctx)
   223  	if ctx.Written() {
   224  		return
   225  	}
   226  	if !issue.IsPull {
   227  		return
   228  	}
   229  	if ctx.HasError() {
   230  		ctx.Flash.Error(ctx.Data["ErrorMsg"].(string))
   231  		ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index))
   232  		return
   233  	}
   234  
   235  	reviewType := form.ReviewType()
   236  	switch reviewType {
   237  	case issues_model.ReviewTypeUnknown:
   238  		ctx.ServerError("ReviewType", fmt.Errorf("unknown ReviewType: %s", form.Type))
   239  		return
   240  
   241  	// can not approve/reject your own PR
   242  	case issues_model.ReviewTypeApprove, issues_model.ReviewTypeReject:
   243  		if issue.IsPoster(ctx.Doer.ID) {
   244  			var translated string
   245  			if reviewType == issues_model.ReviewTypeApprove {
   246  				translated = ctx.Locale.TrString("repo.issues.review.self.approval")
   247  			} else {
   248  				translated = ctx.Locale.TrString("repo.issues.review.self.rejection")
   249  			}
   250  
   251  			ctx.Flash.Error(translated)
   252  			ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index))
   253  			return
   254  		}
   255  	}
   256  
   257  	var attachments []string
   258  	if setting.Attachment.Enabled {
   259  		attachments = form.Files
   260  	}
   261  
   262  	_, comm, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID, attachments)
   263  	if err != nil {
   264  		if issues_model.IsContentEmptyErr(err) {
   265  			ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty"))
   266  			ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index))
   267  		} else if errors.Is(err, pull_service.ErrSubmitReviewOnClosedPR) {
   268  			ctx.Status(http.StatusUnprocessableEntity)
   269  		} else {
   270  			ctx.ServerError("SubmitReview", err)
   271  		}
   272  		return
   273  	}
   274  	ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag()))
   275  }
   276  
   277  // DismissReview dismissing stale review by repo admin
   278  func DismissReview(ctx *context.Context) {
   279  	form := web.GetForm(ctx).(*forms.DismissReviewForm)
   280  	comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true)
   281  	if err != nil {
   282  		if pull_service.IsErrDismissRequestOnClosedPR(err) {
   283  			ctx.Status(http.StatusForbidden)
   284  			return
   285  		}
   286  		ctx.ServerError("pull_service.DismissReview", err)
   287  		return
   288  	}
   289  
   290  	ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag()))
   291  }
   292  
   293  // viewedFilesUpdate Struct to parse the body of a request to update the reviewed files of a PR
   294  // If you want to implement an API to update the review, simply move this struct into modules.
   295  type viewedFilesUpdate struct {
   296  	Files         map[string]bool `json:"files"`
   297  	HeadCommitSHA string          `json:"headCommitSHA"`
   298  }
   299  
   300  func UpdateViewedFiles(ctx *context.Context) {
   301  	// Find corresponding PR
   302  	issue, ok := getPullInfo(ctx)
   303  	if !ok {
   304  		return
   305  	}
   306  	pull := issue.PullRequest
   307  
   308  	var data *viewedFilesUpdate
   309  	err := json.NewDecoder(ctx.Req.Body).Decode(&data)
   310  	if err != nil {
   311  		log.Warn("Attempted to update a review but could not parse request body: %v", err)
   312  		ctx.Resp.WriteHeader(http.StatusBadRequest)
   313  		return
   314  	}
   315  
   316  	// Expect the review to have been now if no head commit was supplied
   317  	if data.HeadCommitSHA == "" {
   318  		data.HeadCommitSHA = pull.HeadCommitID
   319  	}
   320  
   321  	updatedFiles := make(map[string]pull_model.ViewedState, len(data.Files))
   322  	for file, viewed := range data.Files {
   323  		// Only unviewed and viewed are possible, has-changed can not be set from the outside
   324  		state := pull_model.Unviewed
   325  		if viewed {
   326  			state = pull_model.Viewed
   327  		}
   328  		updatedFiles[file] = state
   329  	}
   330  
   331  	if err := pull_model.UpdateReviewState(ctx, ctx.Doer.ID, pull.ID, data.HeadCommitSHA, updatedFiles); err != nil {
   332  		ctx.ServerError("UpdateReview", err)
   333  	}
   334  }