code.gitea.io/gitea@v1.22.3/models/issues/review.go (about) 1 // Copyright 2018 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package issues 5 6 import ( 7 "context" 8 "fmt" 9 "slices" 10 "strings" 11 12 "code.gitea.io/gitea/models/db" 13 git_model "code.gitea.io/gitea/models/git" 14 "code.gitea.io/gitea/models/organization" 15 "code.gitea.io/gitea/models/perm" 16 access_model "code.gitea.io/gitea/models/perm/access" 17 "code.gitea.io/gitea/models/unit" 18 user_model "code.gitea.io/gitea/models/user" 19 "code.gitea.io/gitea/modules/structs" 20 "code.gitea.io/gitea/modules/timeutil" 21 "code.gitea.io/gitea/modules/util" 22 23 "xorm.io/builder" 24 ) 25 26 // ErrReviewNotExist represents a "ReviewNotExist" kind of error. 27 type ErrReviewNotExist struct { 28 ID int64 29 } 30 31 // IsErrReviewNotExist checks if an error is a ErrReviewNotExist. 32 func IsErrReviewNotExist(err error) bool { 33 _, ok := err.(ErrReviewNotExist) 34 return ok 35 } 36 37 func (err ErrReviewNotExist) Error() string { 38 return fmt.Sprintf("review does not exist [id: %d]", err.ID) 39 } 40 41 func (err ErrReviewNotExist) Unwrap() error { 42 return util.ErrNotExist 43 } 44 45 // ErrNotValidReviewRequest an not allowed review request modify 46 type ErrNotValidReviewRequest struct { 47 Reason string 48 UserID int64 49 RepoID int64 50 } 51 52 // IsErrNotValidReviewRequest checks if an error is a ErrNotValidReviewRequest. 53 func IsErrNotValidReviewRequest(err error) bool { 54 _, ok := err.(ErrNotValidReviewRequest) 55 return ok 56 } 57 58 func (err ErrNotValidReviewRequest) Error() string { 59 return fmt.Sprintf("%s [user_id: %d, repo_id: %d]", 60 err.Reason, 61 err.UserID, 62 err.RepoID) 63 } 64 65 func (err ErrNotValidReviewRequest) Unwrap() error { 66 return util.ErrInvalidArgument 67 } 68 69 // ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR. 70 type ErrReviewRequestOnClosedPR struct{} 71 72 // IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR. 73 func IsErrReviewRequestOnClosedPR(err error) bool { 74 _, ok := err.(ErrReviewRequestOnClosedPR) 75 return ok 76 } 77 78 func (err ErrReviewRequestOnClosedPR) Error() string { 79 return "cannot request a re-review on a closed or merged PR" 80 } 81 82 func (err ErrReviewRequestOnClosedPR) Unwrap() error { 83 return util.ErrPermissionDenied 84 } 85 86 // ReviewType defines the sort of feedback a review gives 87 type ReviewType int 88 89 // ReviewTypeUnknown unknown review type 90 const ReviewTypeUnknown ReviewType = -1 91 92 const ( 93 // ReviewTypePending is a review which is not published yet 94 ReviewTypePending ReviewType = iota 95 // ReviewTypeApprove approves changes 96 ReviewTypeApprove 97 // ReviewTypeComment gives general feedback 98 ReviewTypeComment 99 // ReviewTypeReject gives feedback blocking merge 100 ReviewTypeReject 101 // ReviewTypeRequest request review from others 102 ReviewTypeRequest 103 ) 104 105 // Icon returns the corresponding icon for the review type 106 func (rt ReviewType) Icon() string { 107 switch rt { 108 case ReviewTypeApprove: 109 return "check" 110 case ReviewTypeReject: 111 return "diff" 112 case ReviewTypeComment: 113 return "comment" 114 case ReviewTypeRequest: 115 return "dot-fill" 116 default: 117 return "comment" 118 } 119 } 120 121 // Review represents collection of code comments giving feedback for a PR 122 type Review struct { 123 ID int64 `xorm:"pk autoincr"` 124 Type ReviewType 125 Reviewer *user_model.User `xorm:"-"` 126 ReviewerID int64 `xorm:"index"` 127 ReviewerTeamID int64 `xorm:"NOT NULL DEFAULT 0"` 128 ReviewerTeam *organization.Team `xorm:"-"` 129 OriginalAuthor string 130 OriginalAuthorID int64 131 Issue *Issue `xorm:"-"` 132 IssueID int64 `xorm:"index"` 133 Content string `xorm:"TEXT"` 134 // Official is a review made by an assigned approver (counts towards approval) 135 Official bool `xorm:"NOT NULL DEFAULT false"` 136 CommitID string `xorm:"VARCHAR(64)"` 137 Stale bool `xorm:"NOT NULL DEFAULT false"` 138 Dismissed bool `xorm:"NOT NULL DEFAULT false"` 139 140 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 141 UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 142 143 // CodeComments are the initial code comments of the review 144 CodeComments CodeComments `xorm:"-"` 145 146 Comments []*Comment `xorm:"-"` 147 } 148 149 func init() { 150 db.RegisterModel(new(Review)) 151 } 152 153 // LoadCodeComments loads CodeComments 154 func (r *Review) LoadCodeComments(ctx context.Context) (err error) { 155 if r.CodeComments != nil { 156 return err 157 } 158 if err = r.LoadIssue(ctx); err != nil { 159 return err 160 } 161 r.CodeComments, err = fetchCodeCommentsByReview(ctx, r.Issue, nil, r, false) 162 return err 163 } 164 165 func (r *Review) LoadIssue(ctx context.Context) (err error) { 166 if r.Issue != nil { 167 return err 168 } 169 r.Issue, err = GetIssueByID(ctx, r.IssueID) 170 return err 171 } 172 173 // LoadReviewer loads reviewer 174 func (r *Review) LoadReviewer(ctx context.Context) (err error) { 175 if r.ReviewerID == 0 || r.Reviewer != nil { 176 return err 177 } 178 r.Reviewer, err = user_model.GetPossibleUserByID(ctx, r.ReviewerID) 179 if err != nil { 180 if !user_model.IsErrUserNotExist(err) { 181 return fmt.Errorf("GetPossibleUserByID [%d]: %w", r.ReviewerID, err) 182 } 183 r.ReviewerID = user_model.GhostUserID 184 r.Reviewer = user_model.NewGhostUser() 185 return nil 186 } 187 return err 188 } 189 190 // LoadReviewerTeam loads reviewer team 191 func (r *Review) LoadReviewerTeam(ctx context.Context) (err error) { 192 if r.ReviewerTeamID == 0 || r.ReviewerTeam != nil { 193 return nil 194 } 195 196 r.ReviewerTeam, err = organization.GetTeamByID(ctx, r.ReviewerTeamID) 197 return err 198 } 199 200 // LoadAttributes loads all attributes except CodeComments 201 func (r *Review) LoadAttributes(ctx context.Context) (err error) { 202 if err = r.LoadIssue(ctx); err != nil { 203 return err 204 } 205 if err = r.LoadCodeComments(ctx); err != nil { 206 return err 207 } 208 if err = r.LoadReviewer(ctx); err != nil { 209 return err 210 } 211 if err = r.LoadReviewerTeam(ctx); err != nil { 212 return err 213 } 214 return err 215 } 216 217 func (r *Review) HTMLTypeColorName() string { 218 switch r.Type { 219 case ReviewTypeApprove: 220 if r.Stale { 221 return "yellow" 222 } 223 return "green" 224 case ReviewTypeComment: 225 return "grey" 226 case ReviewTypeReject: 227 return "red" 228 case ReviewTypeRequest: 229 return "yellow" 230 } 231 return "grey" 232 } 233 234 // GetReviewByID returns the review by the given ID 235 func GetReviewByID(ctx context.Context, id int64) (*Review, error) { 236 review := new(Review) 237 if has, err := db.GetEngine(ctx).ID(id).Get(review); err != nil { 238 return nil, err 239 } else if !has { 240 return nil, ErrReviewNotExist{ID: id} 241 } 242 return review, nil 243 } 244 245 // CreateReviewOptions represent the options to create a review. Type, Issue and Reviewer are required. 246 type CreateReviewOptions struct { 247 Content string 248 Type ReviewType 249 Issue *Issue 250 Reviewer *user_model.User 251 ReviewerTeam *organization.Team 252 Official bool 253 CommitID string 254 Stale bool 255 } 256 257 // IsOfficialReviewer check if at least one of the provided reviewers can make official reviews in issue (counts towards required approvals) 258 func IsOfficialReviewer(ctx context.Context, issue *Issue, reviewer *user_model.User) (bool, error) { 259 if err := issue.LoadPullRequest(ctx); err != nil { 260 return false, err 261 } 262 263 pr := issue.PullRequest 264 rule, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) 265 if err != nil { 266 return false, err 267 } 268 if rule == nil { 269 // if no rule is found, then user with write access can make official reviews 270 err := pr.LoadBaseRepo(ctx) 271 if err != nil { 272 return false, err 273 } 274 writeAccess, err := access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite) 275 if err != nil { 276 return false, err 277 } 278 return writeAccess, nil 279 } 280 281 official, err := git_model.IsUserOfficialReviewer(ctx, rule, reviewer) 282 if official || err != nil { 283 return official, err 284 } 285 286 return false, nil 287 } 288 289 // IsOfficialReviewerTeam check if reviewer in this team can make official reviews in issue (counts towards required approvals) 290 func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organization.Team) (bool, error) { 291 if err := issue.LoadPullRequest(ctx); err != nil { 292 return false, err 293 } 294 pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, issue.PullRequest.BaseRepoID, issue.PullRequest.BaseBranch) 295 if err != nil { 296 return false, err 297 } 298 if pb == nil { 299 return false, nil 300 } 301 302 if !pb.EnableApprovalsWhitelist { 303 return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil 304 } 305 306 return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil 307 } 308 309 // CreateReview creates a new review based on opts 310 func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) { 311 ctx, committer, err := db.TxContext(ctx) 312 if err != nil { 313 return nil, err 314 } 315 defer committer.Close() 316 sess := db.GetEngine(ctx) 317 318 review := &Review{ 319 Issue: opts.Issue, 320 IssueID: opts.Issue.ID, 321 Reviewer: opts.Reviewer, 322 ReviewerTeam: opts.ReviewerTeam, 323 Content: opts.Content, 324 Official: opts.Official, 325 CommitID: opts.CommitID, 326 Stale: opts.Stale, 327 } 328 329 if opts.Reviewer != nil { 330 review.Type = opts.Type 331 review.ReviewerID = opts.Reviewer.ID 332 333 reviewCond := builder.Eq{"reviewer_id": opts.Reviewer.ID, "issue_id": opts.Issue.ID} 334 // make sure user review requests are cleared 335 if opts.Type != ReviewTypePending { 336 if _, err := sess.Where(reviewCond.And(builder.Eq{"type": ReviewTypeRequest})).Delete(new(Review)); err != nil { 337 return nil, err 338 } 339 } 340 // make sure if the created review gets dismissed no old review surface 341 // other types can be ignored, as they don't affect branch protection 342 if opts.Type == ReviewTypeApprove || opts.Type == ReviewTypeReject { 343 if _, err := sess.Where(reviewCond.And(builder.In("type", ReviewTypeApprove, ReviewTypeReject))). 344 Cols("dismissed").Update(&Review{Dismissed: true}); err != nil { 345 return nil, err 346 } 347 } 348 } else if opts.ReviewerTeam != nil { 349 review.Type = ReviewTypeRequest 350 review.ReviewerTeamID = opts.ReviewerTeam.ID 351 } else { 352 return nil, fmt.Errorf("provide either reviewer or reviewer team") 353 } 354 355 if _, err := sess.Insert(review); err != nil { 356 return nil, err 357 } 358 return review, committer.Commit() 359 } 360 361 // GetCurrentReview returns the current pending review of reviewer for given issue 362 func GetCurrentReview(ctx context.Context, reviewer *user_model.User, issue *Issue) (*Review, error) { 363 if reviewer == nil { 364 return nil, nil 365 } 366 reviews, err := FindReviews(ctx, FindReviewOptions{ 367 Types: []ReviewType{ReviewTypePending}, 368 IssueID: issue.ID, 369 ReviewerID: reviewer.ID, 370 }) 371 if err != nil { 372 return nil, err 373 } 374 if len(reviews) == 0 { 375 return nil, ErrReviewNotExist{} 376 } 377 reviews[0].Reviewer = reviewer 378 reviews[0].Issue = issue 379 return reviews[0], nil 380 } 381 382 // ReviewExists returns whether a review exists for a particular line of code in the PR 383 func ReviewExists(ctx context.Context, issue *Issue, treePath string, line int64) (bool, error) { 384 return db.GetEngine(ctx).Cols("id").Exist(&Comment{IssueID: issue.ID, TreePath: treePath, Line: line, Type: CommentTypeCode}) 385 } 386 387 // ContentEmptyErr represents an content empty error 388 type ContentEmptyErr struct{} 389 390 func (ContentEmptyErr) Error() string { 391 return "Review content is empty" 392 } 393 394 // IsContentEmptyErr returns true if err is a ContentEmptyErr 395 func IsContentEmptyErr(err error) bool { 396 _, ok := err.(ContentEmptyErr) 397 return ok 398 } 399 400 // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist 401 func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, reviewType ReviewType, content, commitID string, stale bool, attachmentUUIDs []string) (*Review, *Comment, error) { 402 ctx, committer, err := db.TxContext(ctx) 403 if err != nil { 404 return nil, nil, err 405 } 406 defer committer.Close() 407 sess := db.GetEngine(ctx) 408 409 official := false 410 411 review, err := GetCurrentReview(ctx, doer, issue) 412 if err != nil { 413 if !IsErrReviewNotExist(err) { 414 return nil, nil, err 415 } 416 417 if reviewType != ReviewTypeApprove && len(strings.TrimSpace(content)) == 0 { 418 return nil, nil, ContentEmptyErr{} 419 } 420 421 if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject { 422 // Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared 423 if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil { 424 return nil, nil, err 425 } 426 if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil { 427 return nil, nil, err 428 } 429 } 430 431 // No current review. Create a new one! 432 if review, err = CreateReview(ctx, CreateReviewOptions{ 433 Type: reviewType, 434 Issue: issue, 435 Reviewer: doer, 436 Content: content, 437 Official: official, 438 CommitID: commitID, 439 Stale: stale, 440 }); err != nil { 441 return nil, nil, err 442 } 443 } else { 444 if err := review.LoadCodeComments(ctx); err != nil { 445 return nil, nil, err 446 } 447 if reviewType != ReviewTypeApprove && len(review.CodeComments) == 0 && len(strings.TrimSpace(content)) == 0 { 448 return nil, nil, ContentEmptyErr{} 449 } 450 451 if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject { 452 // Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared 453 if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil { 454 return nil, nil, err 455 } 456 if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil { 457 return nil, nil, err 458 } 459 } 460 461 review.Official = official 462 review.Issue = issue 463 review.Content = content 464 review.Type = reviewType 465 review.CommitID = commitID 466 review.Stale = stale 467 468 if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil { 469 return nil, nil, err 470 } 471 } 472 473 comm, err := CreateComment(ctx, &CreateCommentOptions{ 474 Type: CommentTypeReview, 475 Doer: doer, 476 Content: review.Content, 477 Issue: issue, 478 Repo: issue.Repo, 479 ReviewID: review.ID, 480 Attachments: attachmentUUIDs, 481 }) 482 if err != nil || comm == nil { 483 return nil, nil, err 484 } 485 486 // try to remove team review request if need 487 if issue.Repo.Owner.IsOrganization() && (reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject) { 488 teamReviewRequests := make([]*Review, 0, 10) 489 if err := sess.SQL("SELECT * FROM review WHERE issue_id = ? AND reviewer_team_id > 0 AND type = ?", issue.ID, ReviewTypeRequest).Find(&teamReviewRequests); err != nil { 490 return nil, nil, err 491 } 492 493 for _, teamReviewRequest := range teamReviewRequests { 494 ok, err := organization.IsTeamMember(ctx, issue.Repo.OwnerID, teamReviewRequest.ReviewerTeamID, doer.ID) 495 if err != nil { 496 return nil, nil, err 497 } else if !ok { 498 continue 499 } 500 501 if _, err := db.DeleteByID[Review](ctx, teamReviewRequest.ID); err != nil { 502 return nil, nil, err 503 } 504 } 505 } 506 507 comm.Review = review 508 return review, comm, committer.Commit() 509 } 510 511 // GetReviewByIssueIDAndUserID get the latest review of reviewer for a pull request 512 func GetReviewByIssueIDAndUserID(ctx context.Context, issueID, userID int64) (*Review, error) { 513 review := new(Review) 514 515 has, err := db.GetEngine(ctx).Where( 516 builder.In("type", ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest). 517 And(builder.Eq{"issue_id": issueID, "reviewer_id": userID, "original_author_id": 0})). 518 Desc("id"). 519 Get(review) 520 if err != nil { 521 return nil, err 522 } 523 524 if !has { 525 return nil, ErrReviewNotExist{} 526 } 527 528 return review, nil 529 } 530 531 // GetTeamReviewerByIssueIDAndTeamID get the latest review request of reviewer team for a pull request 532 func GetTeamReviewerByIssueIDAndTeamID(ctx context.Context, issueID, teamID int64) (*Review, error) { 533 review := new(Review) 534 535 has, err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issueID, "reviewer_team_id": teamID}). 536 Desc("id"). 537 Get(review) 538 if err != nil { 539 return nil, err 540 } 541 542 if !has { 543 return nil, ErrReviewNotExist{0} 544 } 545 546 return review, err 547 } 548 549 // MarkReviewsAsStale marks existing reviews as stale 550 func MarkReviewsAsStale(ctx context.Context, issueID int64) (err error) { 551 _, err = db.GetEngine(ctx).Exec("UPDATE `review` SET stale=? WHERE issue_id=?", true, issueID) 552 553 return err 554 } 555 556 // MarkReviewsAsNotStale marks existing reviews as not stale for a giving commit SHA 557 func MarkReviewsAsNotStale(ctx context.Context, issueID int64, commitID string) (err error) { 558 _, err = db.GetEngine(ctx).Exec("UPDATE `review` SET stale=? WHERE issue_id=? AND commit_id=?", false, issueID, commitID) 559 560 return err 561 } 562 563 // DismissReview change the dismiss status of a review 564 func DismissReview(ctx context.Context, review *Review, isDismiss bool) (err error) { 565 if review.Dismissed == isDismiss || (review.Type != ReviewTypeApprove && review.Type != ReviewTypeReject) { 566 return nil 567 } 568 569 review.Dismissed = isDismiss 570 571 if review.ID == 0 { 572 return ErrReviewNotExist{} 573 } 574 575 _, err = db.GetEngine(ctx).ID(review.ID).Cols("dismissed").Update(review) 576 577 return err 578 } 579 580 // InsertReviews inserts review and review comments 581 func InsertReviews(ctx context.Context, reviews []*Review) error { 582 ctx, committer, err := db.TxContext(ctx) 583 if err != nil { 584 return err 585 } 586 defer committer.Close() 587 sess := db.GetEngine(ctx) 588 589 for _, review := range reviews { 590 if _, err := sess.NoAutoTime().Insert(review); err != nil { 591 return err 592 } 593 594 if _, err := sess.NoAutoTime().Insert(&Comment{ 595 Type: CommentTypeReview, 596 Content: review.Content, 597 PosterID: review.ReviewerID, 598 OriginalAuthor: review.OriginalAuthor, 599 OriginalAuthorID: review.OriginalAuthorID, 600 IssueID: review.IssueID, 601 ReviewID: review.ID, 602 CreatedUnix: review.CreatedUnix, 603 UpdatedUnix: review.UpdatedUnix, 604 }); err != nil { 605 return err 606 } 607 608 for _, c := range review.Comments { 609 c.ReviewID = review.ID 610 } 611 612 if len(review.Comments) > 0 { 613 if _, err := sess.NoAutoTime().Insert(review.Comments); err != nil { 614 return err 615 } 616 } 617 } 618 619 return committer.Commit() 620 } 621 622 // AddReviewRequest add a review request from one reviewer 623 func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) { 624 ctx, committer, err := db.TxContext(ctx) 625 if err != nil { 626 return nil, err 627 } 628 defer committer.Close() 629 sess := db.GetEngine(ctx) 630 631 review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID) 632 if err != nil && !IsErrReviewNotExist(err) { 633 return nil, err 634 } 635 636 if review != nil { 637 // skip it when reviewer hase been request to review 638 if review.Type == ReviewTypeRequest { 639 return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction. 640 } 641 642 if issue.IsClosed { 643 return nil, ErrReviewRequestOnClosedPR{} 644 } 645 646 if issue.IsPull { 647 if err := issue.LoadPullRequest(ctx); err != nil { 648 return nil, err 649 } 650 if issue.PullRequest.HasMerged { 651 return nil, ErrReviewRequestOnClosedPR{} 652 } 653 } 654 } 655 656 // if the reviewer is an official reviewer, 657 // remove the official flag in the all previous reviews 658 official, err := IsOfficialReviewer(ctx, issue, reviewer) 659 if err != nil { 660 return nil, err 661 } else if official { 662 if _, err := sess.Exec("UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, reviewer.ID); err != nil { 663 return nil, err 664 } 665 } 666 667 review, err = CreateReview(ctx, CreateReviewOptions{ 668 Type: ReviewTypeRequest, 669 Issue: issue, 670 Reviewer: reviewer, 671 Official: official, 672 Stale: false, 673 }) 674 if err != nil { 675 return nil, err 676 } 677 678 comment, err := CreateComment(ctx, &CreateCommentOptions{ 679 Type: CommentTypeReviewRequest, 680 Doer: doer, 681 Repo: issue.Repo, 682 Issue: issue, 683 RemovedAssignee: false, // Use RemovedAssignee as !isRequest 684 AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID 685 ReviewID: review.ID, 686 }) 687 if err != nil { 688 return nil, err 689 } 690 691 // func caller use the created comment to retrieve created review too. 692 comment.Review = review 693 694 return comment, committer.Commit() 695 } 696 697 // RemoveReviewRequest remove a review request from one reviewer 698 func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) { 699 ctx, committer, err := db.TxContext(ctx) 700 if err != nil { 701 return nil, err 702 } 703 defer committer.Close() 704 705 review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID) 706 if err != nil && !IsErrReviewNotExist(err) { 707 return nil, err 708 } 709 710 if review == nil || review.Type != ReviewTypeRequest { 711 return nil, nil 712 } 713 714 if _, err = db.DeleteByBean(ctx, review); err != nil { 715 return nil, err 716 } 717 718 official, err := IsOfficialReviewer(ctx, issue, reviewer) 719 if err != nil { 720 return nil, err 721 } else if official { 722 if err := restoreLatestOfficialReview(ctx, issue.ID, reviewer.ID); err != nil { 723 return nil, err 724 } 725 } 726 727 comment, err := CreateComment(ctx, &CreateCommentOptions{ 728 Type: CommentTypeReviewRequest, 729 Doer: doer, 730 Repo: issue.Repo, 731 Issue: issue, 732 RemovedAssignee: true, // Use RemovedAssignee as !isRequest 733 AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID 734 }) 735 if err != nil { 736 return nil, err 737 } 738 739 return comment, committer.Commit() 740 } 741 742 // Recalculate the latest official review for reviewer 743 func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) error { 744 review, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID) 745 if err != nil && !IsErrReviewNotExist(err) { 746 return err 747 } 748 749 if review != nil { 750 if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil { 751 return err 752 } 753 } 754 755 return nil 756 } 757 758 // AddTeamReviewRequest add a review request from one team 759 func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) { 760 ctx, committer, err := db.TxContext(ctx) 761 if err != nil { 762 return nil, err 763 } 764 defer committer.Close() 765 766 review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID) 767 if err != nil && !IsErrReviewNotExist(err) { 768 return nil, err 769 } 770 771 // This team already has been requested to review - therefore skip this. 772 if review != nil { 773 return nil, nil 774 } 775 776 official, err := IsOfficialReviewerTeam(ctx, issue, reviewer) 777 if err != nil { 778 return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err) 779 } else if !official { 780 if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil { 781 return nil, fmt.Errorf("isOfficialReviewer(): %w", err) 782 } 783 } 784 785 if review, err = CreateReview(ctx, CreateReviewOptions{ 786 Type: ReviewTypeRequest, 787 Issue: issue, 788 ReviewerTeam: reviewer, 789 Official: official, 790 Stale: false, 791 }); err != nil { 792 return nil, err 793 } 794 795 if official { 796 if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_team_id=?", false, issue.ID, reviewer.ID); err != nil { 797 return nil, err 798 } 799 } 800 801 comment, err := CreateComment(ctx, &CreateCommentOptions{ 802 Type: CommentTypeReviewRequest, 803 Doer: doer, 804 Repo: issue.Repo, 805 Issue: issue, 806 RemovedAssignee: false, // Use RemovedAssignee as !isRequest 807 AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID 808 ReviewID: review.ID, 809 }) 810 if err != nil { 811 return nil, fmt.Errorf("CreateComment(): %w", err) 812 } 813 814 return comment, committer.Commit() 815 } 816 817 // RemoveTeamReviewRequest remove a review request from one team 818 func RemoveTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) { 819 ctx, committer, err := db.TxContext(ctx) 820 if err != nil { 821 return nil, err 822 } 823 defer committer.Close() 824 825 review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID) 826 if err != nil && !IsErrReviewNotExist(err) { 827 return nil, err 828 } 829 830 if review == nil { 831 return nil, nil 832 } 833 834 if _, err = db.DeleteByBean(ctx, review); err != nil { 835 return nil, err 836 } 837 838 official, err := IsOfficialReviewerTeam(ctx, issue, reviewer) 839 if err != nil { 840 return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err) 841 } 842 843 if official { 844 // recalculate which is the latest official review from that team 845 review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, -reviewer.ID) 846 if err != nil && !IsErrReviewNotExist(err) { 847 return nil, err 848 } 849 850 if review != nil { 851 if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil { 852 return nil, err 853 } 854 } 855 } 856 857 if doer == nil { 858 return nil, committer.Commit() 859 } 860 861 comment, err := CreateComment(ctx, &CreateCommentOptions{ 862 Type: CommentTypeReviewRequest, 863 Doer: doer, 864 Repo: issue.Repo, 865 Issue: issue, 866 RemovedAssignee: true, // Use RemovedAssignee as !isRequest 867 AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID 868 }) 869 if err != nil { 870 return nil, fmt.Errorf("CreateComment(): %w", err) 871 } 872 873 return comment, committer.Commit() 874 } 875 876 // MarkConversation Add or remove Conversation mark for a code comment 877 func MarkConversation(ctx context.Context, comment *Comment, doer *user_model.User, isResolve bool) (err error) { 878 if comment.Type != CommentTypeCode { 879 return nil 880 } 881 882 if isResolve { 883 if comment.ResolveDoerID != 0 { 884 return nil 885 } 886 887 if _, err = db.GetEngine(ctx).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", doer.ID, comment.ID); err != nil { 888 return err 889 } 890 } else { 891 if comment.ResolveDoerID == 0 { 892 return nil 893 } 894 895 if _, err = db.GetEngine(ctx).Exec("UPDATE `comment` SET resolve_doer_id=? WHERE id=?", 0, comment.ID); err != nil { 896 return err 897 } 898 } 899 900 return nil 901 } 902 903 // CanMarkConversation Add or remove Conversation mark for a code comment permission check 904 // the PR writer , offfcial reviewer and poster can do it 905 func CanMarkConversation(ctx context.Context, issue *Issue, doer *user_model.User) (permResult bool, err error) { 906 if doer == nil || issue == nil { 907 return false, fmt.Errorf("issue or doer is nil") 908 } 909 910 if doer.ID != issue.PosterID { 911 if err = issue.LoadRepo(ctx); err != nil { 912 return false, err 913 } 914 915 p, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) 916 if err != nil { 917 return false, err 918 } 919 920 permResult = p.CanAccess(perm.AccessModeWrite, unit.TypePullRequests) 921 if !permResult { 922 if permResult, err = IsOfficialReviewer(ctx, issue, doer); err != nil { 923 return false, err 924 } 925 } 926 927 if !permResult { 928 return false, nil 929 } 930 } 931 932 return true, nil 933 } 934 935 // DeleteReview delete a review and it's code comments 936 func DeleteReview(ctx context.Context, r *Review) error { 937 ctx, committer, err := db.TxContext(ctx) 938 if err != nil { 939 return err 940 } 941 defer committer.Close() 942 943 if r.ID == 0 { 944 return fmt.Errorf("review is not allowed to be 0") 945 } 946 947 if r.Type == ReviewTypeRequest { 948 return fmt.Errorf("review request can not be deleted using this method") 949 } 950 951 opts := FindCommentsOptions{ 952 Type: CommentTypeCode, 953 IssueID: r.IssueID, 954 ReviewID: r.ID, 955 } 956 957 if _, err := db.Delete[Comment](ctx, opts); err != nil { 958 return err 959 } 960 961 opts = FindCommentsOptions{ 962 Type: CommentTypeReview, 963 IssueID: r.IssueID, 964 ReviewID: r.ID, 965 } 966 967 if _, err := db.Delete[Comment](ctx, opts); err != nil { 968 return err 969 } 970 971 opts = FindCommentsOptions{ 972 Type: CommentTypeDismissReview, 973 IssueID: r.IssueID, 974 ReviewID: r.ID, 975 } 976 977 if _, err := db.Delete[Comment](ctx, opts); err != nil { 978 return err 979 } 980 981 if _, err := db.DeleteByID[Review](ctx, r.ID); err != nil { 982 return err 983 } 984 985 if r.Official { 986 if err := restoreLatestOfficialReview(ctx, r.IssueID, r.ReviewerID); err != nil { 987 return err 988 } 989 } 990 991 return committer.Commit() 992 } 993 994 // GetCodeCommentsCount return count of CodeComments a Review has 995 func (r *Review) GetCodeCommentsCount(ctx context.Context) int { 996 opts := FindCommentsOptions{ 997 Type: CommentTypeCode, 998 IssueID: r.IssueID, 999 ReviewID: r.ID, 1000 } 1001 conds := opts.ToConds() 1002 if r.ID == 0 { 1003 conds = conds.And(builder.Eq{"invalidated": false}) 1004 } 1005 1006 count, err := db.GetEngine(ctx).Where(conds).Count(new(Comment)) 1007 if err != nil { 1008 return 0 1009 } 1010 return int(count) 1011 } 1012 1013 // HTMLURL formats a URL-string to the related review issue-comment 1014 func (r *Review) HTMLURL(ctx context.Context) string { 1015 opts := FindCommentsOptions{ 1016 Type: CommentTypeReview, 1017 IssueID: r.IssueID, 1018 ReviewID: r.ID, 1019 } 1020 comment := new(Comment) 1021 has, err := db.GetEngine(ctx).Where(opts.ToConds()).Get(comment) 1022 if err != nil || !has { 1023 return "" 1024 } 1025 return comment.HTMLURL(ctx) 1026 } 1027 1028 // RemapExternalUser ExternalUserRemappable interface 1029 func (r *Review) RemapExternalUser(externalName string, externalID, userID int64) error { 1030 r.OriginalAuthor = externalName 1031 r.OriginalAuthorID = externalID 1032 r.ReviewerID = userID 1033 return nil 1034 } 1035 1036 // GetUserID ExternalUserRemappable interface 1037 func (r *Review) GetUserID() int64 { return r.ReviewerID } 1038 1039 // GetExternalName ExternalUserRemappable interface 1040 func (r *Review) GetExternalName() string { return r.OriginalAuthor } 1041 1042 // GetExternalID ExternalUserRemappable interface 1043 func (r *Review) GetExternalID() int64 { return r.OriginalAuthorID } 1044 1045 // UpdateReviewsMigrationsByType updates reviews' migrations information via given git service type and original id and poster id 1046 func UpdateReviewsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error { 1047 _, err := db.GetEngine(ctx).Table("review"). 1048 Where("original_author_id = ?", originalAuthorID). 1049 And(migratedIssueCond(tp)). 1050 Update(map[string]any{ 1051 "reviewer_id": posterID, 1052 "original_author": "", 1053 "original_author_id": 0, 1054 }) 1055 return err 1056 }