]> source.dussan.org Git - gitea.git/commitdiff
Add dismiss review feature (#12674)
authora1012112796 <1012112796@qq.com>
Thu, 11 Feb 2021 17:32:25 +0000 (01:32 +0800)
committerGitHub <noreply@github.com>
Thu, 11 Feb 2021 17:32:25 +0000 (18:32 +0100)
* Add dismiss review feature

refs:
    https://github.blog/2016-10-12-dismissing-reviews-on-pull-requests/
    https://developer.github.com/v3/pulls/reviews/#dismiss-a-review-for-a-pull-request

* change modal ui and error message

* Add unDismissReview api

Signed-off-by: a1012112796 <1012112796@qq.com>
Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: 6543 <6543@obermui.de>
36 files changed:
integrations/api_pull_review_test.go
models/action.go
models/branches.go
models/fixtures/review.yml
models/issue_comment.go
models/issue_list.go
models/migrations/migrations.go
models/migrations/v170.go [new file with mode: 0644]
models/pull.go
models/review.go
models/review_test.go
modules/convert/pull_review.go
modules/forms/repo_form.go
modules/notification/action/action.go
modules/notification/base/notifier.go
modules/notification/base/null.go
modules/notification/mail/mail.go
modules/notification/notification.go
modules/notification/ui/ui.go
modules/structs/pull_review.go
modules/templates/helper.go
options/locale/locale_en-US.ini
routers/api/v1/api.go
routers/api/v1/repo/pull_review.go
routers/api/v1/swagger/options.go
routers/repo/issue.go
routers/repo/pull_review.go
routers/routes/web.go
services/mailer/mail.go
services/pull/review.go
templates/mail/issue/default.tmpl
templates/repo/issue/view_content/comments.tmpl
templates/repo/issue/view_content/pull.tmpl
templates/swagger/v1_json.tmpl
templates/user/dashboard/feeds.tmpl
web_src/js/index.js

index 261a3a8bfa98d35ff8f70717602aae9ea656863e..8be194602fc3d818d4add2269fb7805dfb010c17 100644 (file)
@@ -111,6 +111,22 @@ func TestAPIPullReview(t *testing.T) {
        assert.EqualValues(t, "APPROVED", review.State)
        assert.EqualValues(t, 3, review.CodeCommentsCount)
 
+       // test dismiss review
+       req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, review.ID, token), &api.DismissPullReviewOptions{
+               Message: "test",
+       })
+       resp = session.MakeRequest(t, req, http.StatusOK)
+       DecodeJSON(t, resp, &review)
+       assert.EqualValues(t, 6, review.ID)
+       assert.EqualValues(t, true, review.Dismissed)
+
+       // test dismiss review
+       req = NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, review.ID, token))
+       resp = session.MakeRequest(t, req, http.StatusOK)
+       DecodeJSON(t, resp, &review)
+       assert.EqualValues(t, 6, review.ID)
+       assert.EqualValues(t, false, review.Dismissed)
+
        // test DeletePullReview
        req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews?token=%s", repo.OwnerName, repo.Name, pullIssue.Index, token), &api.CreatePullReviewOptions{
                Body:  "just a comment",
index 2fdab7f4e9cac5e129a2d1c6932a4e435151a576..e8a1336566e15f4d25e0891fee7be0bc4bf332b5 100644 (file)
@@ -26,30 +26,31 @@ type ActionType int
 
 // Possible action types.
 const (
-       ActionCreateRepo         ActionType = iota + 1 // 1
-       ActionRenameRepo                               // 2
-       ActionStarRepo                                 // 3
-       ActionWatchRepo                                // 4
-       ActionCommitRepo                               // 5
-       ActionCreateIssue                              // 6
-       ActionCreatePullRequest                        // 7
-       ActionTransferRepo                             // 8
-       ActionPushTag                                  // 9
-       ActionCommentIssue                             // 10
-       ActionMergePullRequest                         // 11
-       ActionCloseIssue                               // 12
-       ActionReopenIssue                              // 13
-       ActionClosePullRequest                         // 14
-       ActionReopenPullRequest                        // 15
-       ActionDeleteTag                                // 16
-       ActionDeleteBranch                             // 17
-       ActionMirrorSyncPush                           // 18
-       ActionMirrorSyncCreate                         // 19
-       ActionMirrorSyncDelete                         // 20
-       ActionApprovePullRequest                       // 21
-       ActionRejectPullRequest                        // 22
-       ActionCommentPull                              // 23
-       ActionPublishRelease                           // 24
+       ActionCreateRepo          ActionType = iota + 1 // 1
+       ActionRenameRepo                                // 2
+       ActionStarRepo                                  // 3
+       ActionWatchRepo                                 // 4
+       ActionCommitRepo                                // 5
+       ActionCreateIssue                               // 6
+       ActionCreatePullRequest                         // 7
+       ActionTransferRepo                              // 8
+       ActionPushTag                                   // 9
+       ActionCommentIssue                              // 10
+       ActionMergePullRequest                          // 11
+       ActionCloseIssue                                // 12
+       ActionReopenIssue                               // 13
+       ActionClosePullRequest                          // 14
+       ActionReopenPullRequest                         // 15
+       ActionDeleteTag                                 // 16
+       ActionDeleteBranch                              // 17
+       ActionMirrorSyncPush                            // 18
+       ActionMirrorSyncCreate                          // 19
+       ActionMirrorSyncDelete                          // 20
+       ActionApprovePullRequest                        // 21
+       ActionRejectPullRequest                         // 22
+       ActionCommentPull                               // 23
+       ActionPublishRelease                            // 24
+       ActionPullReviewDismissed                       // 25
 )
 
 // Action represents user operation type and other information to
@@ -259,7 +260,7 @@ func (a *Action) GetCreate() time.Time {
 // GetIssueInfos returns a list of issues associated with
 // the action.
 func (a *Action) GetIssueInfos() []string {
-       return strings.SplitN(a.Content, "|", 2)
+       return strings.SplitN(a.Content, "|", 3)
 }
 
 // GetIssueTitle returns the title of first issue associated
index 80df8c433eb0fa250d98217ce55d5f6de1c92718..440c09309536d5ba58e79a40ad138d89a6881621 100644 (file)
@@ -157,7 +157,8 @@ func (protectBranch *ProtectedBranch) HasEnoughApprovals(pr *PullRequest) bool {
 func (protectBranch *ProtectedBranch) GetGrantedApprovalsCount(pr *PullRequest) int64 {
        sess := x.Where("issue_id = ?", pr.IssueID).
                And("type = ?", ReviewTypeApprove).
-               And("official = ?", true)
+               And("official = ?", true).
+               And("dismissed = ?", false)
        if protectBranch.DismissStaleApprovals {
                sess = sess.And("stale = ?", false)
        }
@@ -178,6 +179,7 @@ func (protectBranch *ProtectedBranch) MergeBlockedByRejectedReview(pr *PullReque
        rejectExist, err := x.Where("issue_id = ?", pr.IssueID).
                And("type = ?", ReviewTypeReject).
                And("official = ?", true).
+               And("dismissed = ?", false).
                Exist(new(Review))
        if err != nil {
                log.Error("MergeBlockedByRejectedReview: %v", err)
index c7c16fb109c3b7dbc55f20febcfedd9eaf84213c..d44d0cde98dc85ae838999d122483665632dc8bb 100644 (file)
   issue_id: 12
   official: true
   updated_unix: 1603196749
-  created_unix: 1603196749
\ No newline at end of file
+  created_unix: 1603196749
index ea179d49d988ab712c7891072d1375beee79af28..b15b5169ff4c4582b03df26613773b79c8179779 100644 (file)
@@ -99,6 +99,8 @@ const (
        CommentTypeProject
        // 31 Project board changed
        CommentTypeProjectBoard
+       // Dismiss Review
+       CommentTypeDismissReview
 )
 
 // CommentTag defines comment tag type
index 628058eb3590312c92632548c3a8a3a56750aaaa..5789ad84ae486fa86f6d444bde282fc0188e9a1e 100644 (file)
@@ -530,7 +530,7 @@ func (issues IssueList) getApprovalCounts(e Engine) (map[int64][]*ReviewCount, e
        }
        sess := e.In("issue_id", ids)
        err := sess.Select("issue_id, type, count(id) as `count`").
-               Where("official = ?", true).
+               Where("official = ? AND dismissed = ?", true, false).
                GroupBy("issue_id, type").
                OrderBy("issue_id").
                Table("review").
index c926ee6ccf191461e33bff7eca9f6f2b9949b5e9..16e2f177ad098f7e85627c7afc51174cbfd19ce6 100644 (file)
@@ -286,6 +286,8 @@ var migrations = []Migration{
        NewMigration("Recreate user table to fix default values", recreateUserTableToFixDefaultValues),
        // v169 -> v170
        NewMigration("Update DeleteBranch comments to set the old_ref to the commit_sha", commentTypeDeleteBranchUseOldRef),
+       // v170 -> v171
+       NewMigration("Add Dismissed to Review table", addDismissedReviewColumn),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v170.go b/models/migrations/v170.go
new file mode 100644 (file)
index 0000000..853a23d
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+       "fmt"
+
+       "xorm.io/xorm"
+)
+
+func addDismissedReviewColumn(x *xorm.Engine) error {
+       type Review struct {
+               Dismissed bool `xorm:"NOT NULL DEFAULT false"`
+       }
+
+       if err := x.Sync2(new(Review)); err != nil {
+               return fmt.Errorf("Sync2: %v", err)
+       }
+       return nil
+}
index 9b6f0830d7df7ecaec59e86a64d7e7fd319ca158..0d4691aac94b68f3b8902ddba8e0c231a2e54c87 100644 (file)
@@ -234,7 +234,7 @@ func (pr *PullRequest) GetApprovalCounts() ([]*ReviewCount, error) {
 func (pr *PullRequest) getApprovalCounts(e Engine) ([]*ReviewCount, error) {
        rCounts := make([]*ReviewCount, 0, 6)
        sess := e.Where("issue_id = ?", pr.IssueID)
-       return rCounts, sess.Select("issue_id, type, count(id) as `count`").Where("official = ?", true).GroupBy("issue_id, type").Table("review").Find(&rCounts)
+       return rCounts, sess.Select("issue_id, type, count(id) as `count`").Where("official = ? AND dismissed = ?", true, false).GroupBy("issue_id, type").Table("review").Find(&rCounts)
 }
 
 // GetApprovers returns the approvers of the pull request
index aeb5f21ea9076fe33881b6553d7f4e628e6b4f53..7775fcdf536fd3b5a5f762e9bfcbff806725f341 100644 (file)
@@ -63,9 +63,10 @@ type Review struct {
        IssueID          int64  `xorm:"index"`
        Content          string `xorm:"TEXT"`
        // Official is a review made by an assigned approver (counts towards approval)
-       Official bool   `xorm:"NOT NULL DEFAULT false"`
-       CommitID string `xorm:"VARCHAR(40)"`
-       Stale    bool   `xorm:"NOT NULL DEFAULT false"`
+       Official  bool   `xorm:"NOT NULL DEFAULT false"`
+       CommitID  string `xorm:"VARCHAR(40)"`
+       Stale     bool   `xorm:"NOT NULL DEFAULT false"`
+       Dismissed bool   `xorm:"NOT NULL DEFAULT false"`
 
        CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
        UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -466,8 +467,8 @@ func GetReviewersByIssueID(issueID int64) ([]*Review, error) {
        }
 
        // Get latest review of each reviwer, sorted in order they were made
-       if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND original_author_id = 0 GROUP BY issue_id, reviewer_id) ORDER BY review.updated_unix ASC",
-               issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest).
+       if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND dismissed = ? AND original_author_id = 0 GROUP BY issue_id, reviewer_id) ORDER BY review.updated_unix ASC",
+               issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, false).
                Find(&reviews); err != nil {
                return nil, err
        }
@@ -558,6 +559,19 @@ func MarkReviewsAsNotStale(issueID int64, commitID string) (err error) {
        return
 }
 
+// DismissReview change the dismiss status of a review
+func DismissReview(review *Review, isDismiss bool) (err error) {
+       if review.Dismissed == isDismiss || (review.Type != ReviewTypeApprove && review.Type != ReviewTypeReject) {
+               return nil
+       }
+
+       review.Dismissed = isDismiss
+
+       _, err = x.Cols("dismissed").Update(review)
+
+       return
+}
+
 // InsertReviews inserts review and review comments
 func InsertReviews(reviews []*Review) error {
        sess := x.NewSession()
index 702e216824041ba791f98c2a3af425083d44e6c2..731565048854e82e032f4b999a93fd97def7d94c 100644 (file)
@@ -142,3 +142,13 @@ func TestGetReviewersByIssueID(t *testing.T) {
                }
        }
 }
+
+func TestDismissReview(t *testing.T) {
+       review1 := AssertExistsAndLoadBean(t, &Review{ID: 9}).(*Review)
+       review2 := AssertExistsAndLoadBean(t, &Review{ID: 11}).(*Review)
+       assert.NoError(t, DismissReview(review1, true))
+       assert.NoError(t, DismissReview(review2, true))
+       assert.NoError(t, DismissReview(review2, true))
+       assert.NoError(t, DismissReview(review2, false))
+       assert.NoError(t, DismissReview(review2, false))
+}
index 0ef1fec39cd57ee6d50c4af358982771ab779f26..d1d6e767d492be0b285242d616fee769bd45df33 100644 (file)
@@ -34,6 +34,7 @@ func ToPullReview(r *models.Review, doer *models.User) (*api.PullReview, error)
                CommitID:          r.CommitID,
                Stale:             r.Stale,
                Official:          r.Official,
+               Dismissed:         r.Dismissed,
                CodeCommentsCount: r.GetCodeCommentsCount(),
                Submitted:         r.CreatedUnix.AsTime(),
                HTMLURL:           r.HTMLURL(),
index f177b21f05ac6cc0e8dda65b2585261c4eeaab27..48af3450f371d8715815287623574a14d894fdfa 100644 (file)
@@ -622,6 +622,12 @@ func (f SubmitReviewForm) HasEmptyContent() bool {
                len(strings.TrimSpace(f.Content)) == 0
 }
 
+// DismissReviewForm for dismissing stale review by repo admin
+type DismissReviewForm struct {
+       ReviewID int64 `binding:"Required"`
+       Message  string
+}
+
 // __________       .__
 // \______   \ ____ |  |   ____ _____    ______ ____
 //  |       _// __ \|  | _/ __ \\__  \  /  ___// __ \
index 360906f076e98f2f925733f7854ab5ecc33bddcf..836cb51b3ebde559f17d0d64414824fbb27a3c89 100644 (file)
@@ -275,6 +275,26 @@ func (*actionNotifier) NotifyMergePullRequest(pr *models.PullRequest, doer *mode
        }
 }
 
+func (*actionNotifier) NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
+       reviewerName := review.Reviewer.Name
+       if len(review.OriginalAuthor) > 0 {
+               reviewerName = review.OriginalAuthor
+       }
+       if err := models.NotifyWatchers(&models.Action{
+               ActUserID: doer.ID,
+               ActUser:   doer,
+               OpType:    models.ActionPullReviewDismissed,
+               Content:   fmt.Sprintf("%d|%s|%s", review.Issue.Index, reviewerName, comment.Content),
+               RepoID:    review.Issue.Repo.ID,
+               Repo:      review.Issue.Repo,
+               IsPrivate: review.Issue.Repo.IsPrivate,
+               CommentID: comment.ID,
+               Comment:   comment,
+       }); err != nil {
+               log.Error("NotifyWatchers [%d]: %v", review.Issue.ID, err)
+       }
+}
+
 func (a *actionNotifier) NotifyPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
        data, err := json.Marshal(commits)
        if err != nil {
index b01026dfc5859b93a49d7c1d0b7b2c334c4f2d25..5bb833d275170d4cd271eb9c793d36ec1ec9f542 100644 (file)
@@ -39,6 +39,7 @@ type Notifier interface {
        NotifyPullRequestCodeComment(pr *models.PullRequest, comment *models.Comment, mentions []*models.User)
        NotifyPullRequestChangeTargetBranch(doer *models.User, pr *models.PullRequest, oldBranch string)
        NotifyPullRequestPushCommits(doer *models.User, pr *models.PullRequest, comment *models.Comment)
+       NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment)
 
        NotifyCreateIssueComment(doer *models.User, repo *models.Repository,
                issue *models.Issue, comment *models.Comment, mentions []*models.User)
index d80ba859f3c95939af58499e90c2a20a0df3cbeb..2386f925cec6adefb9c71cf5ef7e0c013394e63d 100644 (file)
@@ -62,6 +62,10 @@ func (*NullNotifier) NotifyPullRequestChangeTargetBranch(doer *models.User, pr *
 func (*NullNotifier) NotifyPullRequestPushCommits(doer *models.User, pr *models.PullRequest, comment *models.Comment) {
 }
 
+// NotifyPullRevieweDismiss notifies when a review was dismissed by repo admin
+func (*NullNotifier) NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
+}
+
 // NotifyUpdateComment places a place holder function
 func (*NullNotifier) NotifyUpdateComment(doer *models.User, c *models.Comment, oldContent string) {
 }
index ee8a0c436c690e687b82f434c153b959abd4e6f3..f984ea7661ae6d791c23c85e8bd2934b61cc15b3 100644 (file)
@@ -152,6 +152,12 @@ func (m *mailNotifier) NotifyPullRequestPushCommits(doer *models.User, pr *model
        m.NotifyCreateIssueComment(doer, comment.Issue.Repo, comment.Issue, comment, nil)
 }
 
+func (m *mailNotifier) NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
+       if err := mailer.MailParticipantsComment(comment, models.ActionPullReviewDismissed, review.Issue, []*models.User{}); err != nil {
+               log.Error("MailParticipantsComment: %v", err)
+       }
+}
+
 func (m *mailNotifier) NotifyNewRelease(rel *models.Release) {
        if err := rel.LoadAttributes(); err != nil {
                log.Error("NotifyNewRelease: %v", err)
index 7ced57ce2d9288e03d4eb6937e4277993618bafc..d22d157bec18eff0f1157ad6f4955ad4207ae9fb 100644 (file)
@@ -108,6 +108,13 @@ func NotifyPullRequestPushCommits(doer *models.User, pr *models.PullRequest, com
        }
 }
 
+// NotifyPullRevieweDismiss notifies when a review was dismissed by repo admin
+func NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
+       for _, notifier := range notifiers {
+               notifier.NotifyPullRevieweDismiss(doer, review, comment)
+       }
+}
+
 // NotifyUpdateComment notifies update comment to notifiers
 func NotifyUpdateComment(doer *models.User, c *models.Comment, oldContent string) {
        for _, notifier := range notifiers {
index 8e510e9cd457307009ad2fa65919b369048dda65..25ea4d91c643fef61de857781ad49f08bcaab81e 100644 (file)
@@ -161,6 +161,15 @@ func (ns *notificationService) NotifyPullRequestPushCommits(doer *models.User, p
        _ = ns.issueQueue.Push(opts)
 }
 
+func (ns *notificationService) NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
+       var opts = issueNotificationOpts{
+               IssueID:              review.IssueID,
+               NotificationAuthorID: doer.ID,
+               CommentID:            comment.ID,
+       }
+       _ = ns.issueQueue.Push(opts)
+}
+
 func (ns *notificationService) NotifyIssueChangeAssignee(doer *models.User, issue *models.Issue, assignee *models.User, removed bool, comment *models.Comment) {
        if !removed {
                var opts = issueNotificationOpts{
index 07fa968d28779387cfea540be56d90806844fb77..261d00fde87863524703bed6322d0ad115f90ef8 100644 (file)
@@ -36,6 +36,7 @@ type PullReview struct {
        CommitID          string          `json:"commit_id"`
        Stale             bool            `json:"stale"`
        Official          bool            `json:"official"`
+       Dismissed         bool            `json:"dismissed"`
        CodeCommentsCount int             `json:"comments_count"`
        // swagger:strfmt date-time
        Submitted time.Time `json:"submitted_at"`
@@ -92,6 +93,11 @@ type SubmitPullReviewOptions struct {
        Body  string          `json:"body"`
 }
 
+// DismissPullReviewOptions are options to dismiss a pull review
+type DismissPullReviewOptions struct {
+       Message string `json:"message"`
+}
+
 // PullReviewRequestOptions are options to add or remove pull review requests
 type PullReviewRequestOptions struct {
        Reviewers     []string `json:"reviewers"`
index 987a6ad9833a0ee7e78a7bc43e617b335a174bfa..b8e4f5d5055ed2dc8c5c9fc3cc46ffa8e2ca83f0 100644 (file)
@@ -798,6 +798,8 @@ func ActionIcon(opType models.ActionType) string {
                return "diff"
        case models.ActionPublishRelease:
                return "tag"
+       case models.ActionPullReviewDismissed:
+               return "x"
        default:
                return "question"
        }
index a4b677e43baf75b04dbeb4c40087fe3aaa902fab..767696cfb901cade2c5a3d2253f50500a884ceed 100644 (file)
@@ -76,6 +76,7 @@ pull_requests = Pull Requests
 issues = Issues
 milestones = Milestones
 
+ok = OK
 cancel = Cancel
 save = Save
 add = Add
@@ -1104,6 +1105,8 @@ issues.re_request_review=Re-request review
 issues.is_stale = There have been changes to this PR since this review
 issues.remove_request_review=Remove review request
 issues.remove_request_review_block=Can't remove review request
+issues.dismiss_review = Dismiss Review
+issues.dismiss_review_warning = Are you sure you want to dismiss this review?
 issues.sign_in_require_desc = <a href="%s">Sign in</a> to join this conversation.
 issues.edit = Edit
 issues.cancel = Cancel
@@ -1216,6 +1219,8 @@ issues.review.self.approval = You cannot approve your own pull request.
 issues.review.self.rejection = You cannot request changes on your own pull request.
 issues.review.approve = "approved these changes %s"
 issues.review.comment = "reviewed %s"
+issues.review.dismissed = "dismissed %s’s review %s"
+issues.review.dismissed_label = Dismissed
 issues.review.left_comment = left a comment
 issues.review.content.empty = You need to leave a comment indicating the requested change(s).
 issues.review.reject = "requested changes %s"
@@ -2519,6 +2524,8 @@ mirror_sync_delete = synced and deleted reference <code>%[2]s</code> at <a href=
 approve_pull_request = `approved <a href="%s/pulls/%s">%s#%[2]s</a>`
 reject_pull_request = `suggested changes for <a href="%s/pulls/%s">%s#%[2]s</a>`
 publish_release  = `released <a href="%s/releases/tag/%s"> "%[4]s" </a> at <a href="%[1]s">%[3]s</a>`
+review_dismissed = `dismissed review from <b>%[4]s</b> for <a href="%[1]s/pulls/%[2]s">%[3]s#%[2]s</a>`
+review_dismissed_reason = Reason:
 create_branch = created branch <a href="%[1]s/src/branch/%[2]s">%[3]s</a> in <a href="%[1]s">%[4]s</a>
 
 [tool]
index 9c21107a2892c2b6752212a1daad47f6e94a6cef..85c4e4d5bfa0e13a2a1f4f4e45c5d988afba4c3e 100644 (file)
@@ -891,6 +891,8 @@ func Routes() *web.Route {
                                                                        Post(reqToken(), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview)
                                                                m.Combo("/comments").
                                                                        Get(repo.GetPullReviewComments)
+                                                               m.Post("/dismissals", reqToken(), bind(api.DismissPullReviewOptions{}), repo.DismissPullReview)
+                                                               m.Post("/undismissals", reqToken(), repo.UnDismissPullReview)
                                                        })
                                                })
                                                m.Combo("/requested_reviewers").
index d39db4c6605a8ce08ec8b7e5687a0e7fae44cad4..63179aa9907dbc972bc11ac252a81d114772fbee 100644 (file)
@@ -757,3 +757,129 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
                return
        }
 }
+
+// DismissPullReview dismiss a review for a pull request
+func DismissPullReview(ctx *context.APIContext) {
+       // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals repository repoDismissPullReview
+       // ---
+       // summary: Dismiss a review for a pull request
+       // produces:
+       // - application/json
+       // parameters:
+       // - name: owner
+       //   in: path
+       //   description: owner of the repo
+       //   type: string
+       //   required: true
+       // - name: repo
+       //   in: path
+       //   description: name of the repo
+       //   type: string
+       //   required: true
+       // - name: index
+       //   in: path
+       //   description: index of the pull request
+       //   type: integer
+       //   format: int64
+       //   required: true
+       // - name: id
+       //   in: path
+       //   description: id of the review
+       //   type: integer
+       //   format: int64
+       //   required: true
+       // - name: body
+       //   in: body
+       //   required: true
+       //   schema:
+       //     "$ref": "#/definitions/DismissPullReviewOptions"
+       // responses:
+       //   "200":
+       //     "$ref": "#/responses/PullReview"
+       //   "403":
+       //     "$ref": "#/responses/forbidden"
+       //   "422":
+       //     "$ref": "#/responses/validationError"
+       opts := web.GetForm(ctx).(*api.DismissPullReviewOptions)
+       dismissReview(ctx, opts.Message, true)
+}
+
+// UnDismissPullReview cancel to dismiss a review for a pull request
+func UnDismissPullReview(ctx *context.APIContext) {
+       // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals repository repoUnDismissPullReview
+       // ---
+       // summary: Cancel to dismiss a review for a pull request
+       // produces:
+       // - application/json
+       // parameters:
+       // - name: owner
+       //   in: path
+       //   description: owner of the repo
+       //   type: string
+       //   required: true
+       // - name: repo
+       //   in: path
+       //   description: name of the repo
+       //   type: string
+       //   required: true
+       // - name: index
+       //   in: path
+       //   description: index of the pull request
+       //   type: integer
+       //   format: int64
+       //   required: true
+       // - name: id
+       //   in: path
+       //   description: id of the review
+       //   type: integer
+       //   format: int64
+       //   required: true
+       // responses:
+       //   "200":
+       //     "$ref": "#/responses/PullReview"
+       //   "403":
+       //     "$ref": "#/responses/forbidden"
+       //   "422":
+       //     "$ref": "#/responses/validationError"
+       dismissReview(ctx, "", false)
+}
+
+func dismissReview(ctx *context.APIContext, msg string, isDismiss bool) {
+       if !ctx.Repo.IsAdmin() {
+               ctx.Error(http.StatusForbidden, "", "Must be repo admin")
+               return
+       }
+       review, pr, isWrong := prepareSingleReview(ctx)
+       if isWrong {
+               return
+       }
+
+       if review.Type != models.ReviewTypeApprove && review.Type != models.ReviewTypeReject {
+               ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because it's type is not Approve or change request")
+               return
+       }
+
+       if pr.Issue.IsClosed {
+               ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because this pr is closed")
+               return
+       }
+
+       _, err := pull_service.DismissReview(review.ID, msg, ctx.User, isDismiss)
+       if err != nil {
+               ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err)
+               return
+       }
+
+       if review, err = models.GetReviewByID(review.ID); err != nil {
+               ctx.Error(http.StatusInternalServerError, "GetReviewByID", err)
+               return
+       }
+
+       // convert response
+       apiReview, err := convert.ToPullReview(review, ctx.User)
+       if err != nil {
+               ctx.Error(http.StatusInternalServerError, "convertToPullReview", err)
+               return
+       }
+       ctx.JSON(http.StatusOK, apiReview)
+}
index 8919a969ec7ced93143e50673a238c8a656d4d20..a2dc2193a8f9f75fe3ef1e07e7d8d5a4a4cfc31f 100644 (file)
@@ -150,6 +150,9 @@ type swaggerParameterBodies struct {
        // in:body
        SubmitPullReviewOptions api.SubmitPullReviewOptions
 
+       // in:body
+       DismissPullReviewOptions api.DismissPullReviewOptions
+
        // in:body
        MigrateRepoOptions api.MigrateRepoOptions
 
index 71c8f1efbb4b486d3fe58ce0b869e11db389ba37..fa1ee9977165ac1804dab9ee9c4975932424a86a 100644 (file)
@@ -1364,7 +1364,7 @@ func ViewIssue(ctx *context.Context) {
                                        return
                                }
                        }
-               } else if comment.Type == models.CommentTypeCode || comment.Type == models.CommentTypeReview {
+               } else if comment.Type == models.CommentTypeCode || comment.Type == models.CommentTypeReview || comment.Type == models.CommentTypeDismissReview {
                        comment.RenderedContent = string(markdown.Render([]byte(comment.Content), ctx.Repo.RepoLink,
                                ctx.Repo.Repository.ComposeMetas()))
                        if err = comment.LoadReview(); err != nil && !models.IsErrReviewNotExist(err) {
index df49b6cfe126e8ecde43016dd4dabe742d1c6070..89e87ccc442e8fe2c63a2e078b45cc0a69877cbc 100644 (file)
@@ -223,3 +223,15 @@ func SubmitReview(ctx *context.Context) {
 
        ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag()))
 }
+
+// DismissReview dismissing stale review by repo admin
+func DismissReview(ctx *context.Context) {
+       form := web.GetForm(ctx).(*auth.DismissReviewForm)
+       comm, err := pull_service.DismissReview(form.ReviewID, form.Message, ctx.User, true)
+       if err != nil {
+               ctx.ServerError("pull_service.DismissReview", err)
+               return
+       }
+
+       ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, comm.Issue.Index, comm.HashTag()))
+}
index 9e3e690fb965ff80349ffcb390a6017142337350..2f28e567f9f4f6f929f184cf0ad3482bf87e7a6a 100644 (file)
@@ -734,6 +734,7 @@ func RegisterRoutes(m *web.Route) {
                        m.Post("/projects", reqRepoIssuesOrPullsWriter, repo.UpdateIssueProject)
                        m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
                        m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest)
+                       m.Post("/dismiss_review", reqRepoAdmin, bindIgnErr(auth.DismissReviewForm{}), repo.DismissReview)
                        m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
                        m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
                        m.Post("/attachments", repo.UploadIssueAttachment)
index b4217c046612bbc463d002315fded0890bf05d29..e87d34ab2952199add5e1ab8757e74d32ea34585 100644 (file)
@@ -304,6 +304,8 @@ func actionToTemplate(issue *models.Issue, actionType models.ActionType,
                name = "reopen"
        case models.ActionMergePullRequest:
                name = "merge"
+       case models.ActionPullReviewDismissed:
+               name = "review_dismissed"
        default:
                switch commentType {
                case models.CommentTypeReview:
index 8994a9e78ac37e16db75c79eb4a01b995c101aa6..4e77e11daa2fa0ffb4bf4b426862fead4425d466 100644 (file)
@@ -253,3 +253,54 @@ func SubmitReview(doer *models.User, gitRepo *git.Repository, issue *models.Issu
 
        return review, comm, nil
 }
+
+// DismissReview dismissing stale review by repo admin
+func DismissReview(reviewID int64, message string, doer *models.User, isDismiss bool) (comment *models.Comment, err error) {
+       review, err := models.GetReviewByID(reviewID)
+       if err != nil {
+               return
+       }
+
+       if review.Type != models.ReviewTypeApprove && review.Type != models.ReviewTypeReject {
+               return nil, fmt.Errorf("not need to dismiss this review because it's type is not Approve or change request")
+       }
+
+       if err = models.DismissReview(review, isDismiss); err != nil {
+               return
+       }
+
+       if !isDismiss {
+               return nil, nil
+       }
+
+       // load data for notify
+       if err = review.LoadAttributes(); err != nil {
+               return
+       }
+       if err = review.Issue.LoadPullRequest(); err != nil {
+               return
+       }
+       if err = review.Issue.LoadAttributes(); err != nil {
+               return
+       }
+
+       comment, err = models.CreateComment(&models.CreateCommentOptions{
+               Doer:     doer,
+               Content:  message,
+               Type:     models.CommentTypeDismissReview,
+               ReviewID: review.ID,
+               Issue:    review.Issue,
+               Repo:     review.Issue.Repo,
+       })
+       if err != nil {
+               return
+       }
+
+       comment.Review = review
+       comment.Poster = doer
+       comment.Issue = review.Issue
+
+       notification.NotifyPullRevieweDismiss(doer, review, comment)
+
+       return
+}
index e062dca7f1b5d2cb5ebf459025063689767372b2..b7d576bef4adf9cafb33dbaba13e27e2ed4ebdb1 100644 (file)
@@ -49,6 +49,8 @@
                        <b>@{{.Doer.Name}}</b> requested changes on this pull request.
                {{else if eq .ActionName "review"}}
                        <b>@{{.Doer.Name}}</b> commented on this pull request.
+               {{else if eq .ActionName "review_dismissed"}}
+                       <b>@{{.Doer.Name}}</b> dismissed last review from {{.Comment.Review.Reviewer.Name}} for this pull request.
                {{end}}
 
                {{- if eq .Body ""}}
index 63fe73857ca5a6850661ae63be5a39b4c20d0a14..b971c6b1ae69d282973b823288a599d69279b9ff 100644 (file)
@@ -8,7 +8,8 @@
         18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY, 21 = CODE,
         22 = REVIEW, 23 = ISSUE_LOCKED, 24 = ISSUE_UNLOCKED, 25 = TARGET_BRANCH_CHANGED,
         26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST,
-        29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED -->
+        29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED 
+        32 = DISMISSED_REVIEW -->
        {{if eq .Type 0}}
                <div class="timeline-item comment" id="{{.HashTag}}">
                {{if .OriginalAuthor }}
                                        {{else}}
                                                {{$.i18n.Tr "repo.issues.review.comment" $createdStr | Safe}}
                                        {{end}}
+                                       {{if .Review.Dismissed}}
+                                               <div class="ui small label">{{$.i18n.Tr "repo.issues.review.dismissed_label"}}</div>
+                                       {{end}}
                                </span>
                        </div>
                        {{if .Content}}
                        </span>
                </div>
                {{end}}
+       {{else if eq .Type 32}}
+               <div class="timeline-item-group">
+                       <div class="timeline-item event" id="{{.HashTag}}">
+                               <a class="timeline-avatar"{{if gt .Poster.ID 0}} href="{{.Poster.HomeLink}}"{{end}}>
+                                       <img src="{{.Poster.RelAvatarLink}}">
+                               </a>
+                               <span class="badge grey">{{svg "octicon-x" 16}}</span>
+                               <span class="text grey">
+                                       <a class="author"{{if gt .Poster.ID 0}} href="{{.Poster.HomeLink}}"{{end}}>{{.Poster.GetDisplayName}}</a>
+                                       {{$reviewerName := ""}}
+                                       {{if eq .Review.OriginalAuthor ""}}
+                                               {{$reviewerName = .Review.Reviewer.Name}}
+                                       {{else}}
+                                               {{$reviewerName = .Review.OriginalAuthor}}
+                                       {{end}}
+                                       {{$.i18n.Tr "repo.issues.review.dismissed" $reviewerName $createdStr | Safe}}
+                               </span>
+                       </div>
+                       {{if .Content}}
+                               <div class="timeline-item comment">
+                                       <div class="content">
+                                               <div class="ui top attached header arrow-top">
+                                                       <span class="text grey">
+                                                               {{$.i18n.Tr "action.review_dismissed_reason"}}
+                                                       </span>
+                                               </div>
+                                               <div class="ui attached segment">
+                                                       <div class="render-content markdown">
+                                                               {{if .RenderedContent}}
+                                                                       {{.RenderedContent|Str2html}}
+                                                               {{else}}
+                                                                       <span class="no-content">{{$.i18n.Tr "repo.issues.no_content"}}</span>
+                                                               {{end}}
+                                                       </div>
+                                               </div>
+                                       </div>
+                               </div>
+                       {{end}}
+               </div>
        {{end}}
 {{end}}
index 34eaa83eb2d5510aa0789c329b16f02509128dda..9e883c0a9352d477c0d039e68f43440211a36f88 100644 (file)
                                                </div>
                                                <div class="review-item-right">
                                                        {{if .Review.Stale}}
-                                                       <span class="ui poping up type-icon text grey" data-content="{{$.i18n.Tr "repo.issues.is_stale"}}">
-                                                               <i class="octicon icon fa-hourglass-end"></i>
-                                                       </span>
+                                                               <span class="ui poping up type-icon text grey" data-content="{{$.i18n.Tr "repo.issues.is_stale"}}">
+                                                                       <i class="octicon icon fa-hourglass-end"></i>
+                                                               </span>
+                                                       {{end}}
+                                                       {{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed))}}
+                                                               <a href="#" class="ui grey poping up icon dismiss-review-btn" data-review-id="dismiss-review-{{.Review.ID}}" data-content="{{$.i18n.Tr "repo.issues.dismiss_review"}}">
+                                                                       {{svg "octicon-x" 16}}
+                                                               </a>
+                                                               <div class="ui small modal" id="dismiss-review-modal">
+                                                                       <div class="header">
+                                                                               {{$.i18n.Tr "repo.issues.dismiss_review"}}
+                                                                       </div>
+                                                                       <div class="content">
+                                                                               <div class="ui warning message text left">
+                                                                                       {{$.i18n.Tr "repo.issues.dismiss_review_warning"}}
+                                                                               </div>
+                                                                               <form class="ui form dismiss-review-form" id="dismiss-review-{{.Review.ID}}" action="{{$.RepoLink}}/issues/dismiss_review" method="post">
+                                                                                       {{$.CsrfTokenHtml}}
+                                                                                       <input type="hidden" name="review_id" value="{{.Review.ID}}">
+                                                                                       <div class="field">
+                                                                                               <label for="message">{{$.i18n.Tr "action.review_dismissed_reason"}}</label>
+                                                                                               <input id="message" name="message">
+                                                                                       </div>
+                                                                                       <div class="text right actions">
+                                                                                               <div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div>
+                                                                                               <button class="ui red button" type="submit">{{$.i18n.Tr "ok"}}</button>
+                                                                                       </div>
+                                                                               </form>
+                                                                       </div>
+                                                               </div>
                                                        {{end}}
                                                        <span class="type-icon text {{if eq .Review.Type 1}}green
                                                                {{- else if eq .Review.Type 2}}grey
index 28aa617799d213f9c59891768130ddb089245ce4..94493749afa4abd3dd7309e1eed901ddc9b8cdea 100644 (file)
         }
       }
     },
+    "/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Dismiss a review for a pull request",
+        "operationId": "repoDismissPullReview",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "index of the pull request",
+            "name": "index",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "id of the review",
+            "name": "id",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/DismissPullReviewOptions"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/PullReview"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Cancel to dismiss a review for a pull request",
+        "operationId": "repoUnDismissPullReview",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "index of the pull request",
+            "name": "index",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "integer",
+            "format": "int64",
+            "description": "id of the review",
+            "name": "id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/PullReview"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/pulls/{index}/update": {
       "post": {
         "produces": [
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "DismissPullReviewOptions": {
+      "description": "DismissPullReviewOptions are options to dismiss a pull review",
+      "type": "object",
+      "properties": {
+        "message": {
+          "type": "string",
+          "x-go-name": "Message"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "EditAttachmentOptions": {
       "description": "EditAttachmentOptions options for editing attachments",
       "type": "object",
           "type": "string",
           "x-go-name": "CommitID"
         },
+        "dismissed": {
+          "type": "boolean",
+          "x-go-name": "Dismissed"
+        },
         "html_url": {
           "type": "string",
           "x-go-name": "HTMLURL"
index 744e028bc2db92f668e8c433abaaa355c568c715..d25920a24ee4eea5fabcbd510ff55de0ad0ba8b6 100644 (file)
                                                        {{ $branchLink := .GetBranch | EscapePound | Escape}}
                                                        {{ $linkText := .Content | RenderEmoji }}
                                                        {{$.i18n.Tr "action.publish_release" .GetRepoLink $branchLink .ShortRepoPath $linkText | Str2html}}
+                                               {{else if eq .GetOpType 25}}
+                                                       {{ $index := index .GetIssueInfos 0}}
+                                                       {{ $reviewer := index .GetIssueInfos 1}}
+                                                       {{$.i18n.Tr "action.review_dismissed" .GetRepoLink $index .ShortRepoPath $reviewer | Str2html}}
                                                {{end}}
                                        </p>
                                        {{if or (eq .GetOpType 5) (eq .GetOpType 18)}}
                                                <p class="text light grey">{{index .GetIssueInfos 1}}</p>
                                        {{else if or (eq .GetOpType 12) (eq .GetOpType 13) (eq .GetOpType 14) (eq .GetOpType 15)}}
                                                <span class="text truncate issue title">{{.GetIssueTitle | RenderEmoji}}</span>
+                                       {{else if eq .GetOpType 25}}
+                                       <p class="text light grey">{{$.i18n.Tr "action.review_dismissed_reason"}}</p>
+                                               <p class="text light grey">{{index .GetIssueInfos 2 | RenderEmoji}}</p>
                                        {{end}}
                                        <p class="text italic light grey">{{TimeSince .GetCreate $.i18n.Lang}}</p>
                                </div>
index f5f4841410b4116cd6e3fe575bcf41cf306ba9fa..0d60c21ccacc19bd20b3a69b6066e233b5761c9c 100644 (file)
@@ -677,6 +677,13 @@ function initIssueComments() {
     return false;
   });
 
+  $('.dismiss-review-btn').on('click', function (e) {
+    e.preventDefault();
+    const $this = $(this);
+    const $dismissReviewModal = $this.next();
+    $dismissReviewModal.modal('show');
+  });
+
   $(document).on('click', (event) => {
     const urlTarget = $(':target');
     if (urlTarget.length === 0) return;