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