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 }