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 }