diff options
author | a1012112796 <1012112796@qq.com> | 2021-02-12 01:32:25 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-11 18:32:25 +0100 |
commit | ac701637b42d2d6bb5fe9b258f3f54959b6a505e (patch) | |
tree | 3020b45f25405036036c7d0cc0a7fc5007b5ab60 | |
parent | c69c01d2b6b08a89448b5596fd2233fa4e802ac3 (diff) | |
download | gitea-ac701637b42d2d6bb5fe9b258f3f54959b6a505e.tar.gz gitea-ac701637b42d2d6bb5fe9b258f3f54959b6a505e.zip |
Add dismiss review feature (#12674)
* 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, 593 insertions, 39 deletions
diff --git a/integrations/api_pull_review_test.go b/integrations/api_pull_review_test.go index 261a3a8bfa..8be194602f 100644 --- a/integrations/api_pull_review_test.go +++ b/integrations/api_pull_review_test.go @@ -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", diff --git a/models/action.go b/models/action.go index 2fdab7f4e9..e8a1336566 100644 --- a/models/action.go +++ b/models/action.go @@ -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 diff --git a/models/branches.go b/models/branches.go index 80df8c433e..440c093095 100644 --- a/models/branches.go +++ b/models/branches.go @@ -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) diff --git a/models/fixtures/review.yml b/models/fixtures/review.yml index c7c16fb109..d44d0cde98 100644 --- a/models/fixtures/review.yml +++ b/models/fixtures/review.yml @@ -104,4 +104,4 @@ issue_id: 12 official: true updated_unix: 1603196749 - created_unix: 1603196749
\ No newline at end of file + created_unix: 1603196749 diff --git a/models/issue_comment.go b/models/issue_comment.go index ea179d49d9..b15b5169ff 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -99,6 +99,8 @@ const ( CommentTypeProject // 31 Project board changed CommentTypeProjectBoard + // Dismiss Review + CommentTypeDismissReview ) // CommentTag defines comment tag type diff --git a/models/issue_list.go b/models/issue_list.go index 628058eb35..5789ad84ae 100644 --- a/models/issue_list.go +++ b/models/issue_list.go @@ -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"). diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c926ee6ccf..16e2f177ad 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 index 0000000000..853a23d290 --- /dev/null +++ b/models/migrations/v170.go @@ -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 +} diff --git a/models/pull.go b/models/pull.go index 9b6f0830d7..0d4691aac9 100644 --- a/models/pull.go +++ b/models/pull.go @@ -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 diff --git a/models/review.go b/models/review.go index aeb5f21ea9..7775fcdf53 100644 --- a/models/review.go +++ b/models/review.go @@ -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() diff --git a/models/review_test.go b/models/review_test.go index 702e216824..7315650488 100644 --- a/models/review_test.go +++ b/models/review_test.go @@ -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)) +} diff --git a/modules/convert/pull_review.go b/modules/convert/pull_review.go index 0ef1fec39c..d1d6e767d4 100644 --- a/modules/convert/pull_review.go +++ b/modules/convert/pull_review.go @@ -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(), diff --git a/modules/forms/repo_form.go b/modules/forms/repo_form.go index f177b21f05..48af3450f3 100644 --- a/modules/forms/repo_form.go +++ b/modules/forms/repo_form.go @@ -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 +} + // __________ .__ // \______ \ ____ | | ____ _____ ______ ____ // | _// __ \| | _/ __ \\__ \ / ___// __ \ diff --git a/modules/notification/action/action.go b/modules/notification/action/action.go index 360906f076..836cb51b3e 100644 --- a/modules/notification/action/action.go +++ b/modules/notification/action/action.go @@ -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 { diff --git a/modules/notification/base/notifier.go b/modules/notification/base/notifier.go index b01026dfc5..5bb833d275 100644 --- a/modules/notification/base/notifier.go +++ b/modules/notification/base/notifier.go @@ -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) diff --git a/modules/notification/base/null.go b/modules/notification/base/null.go index d80ba859f3..2386f925ce 100644 --- a/modules/notification/base/null.go +++ b/modules/notification/base/null.go @@ -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) { } diff --git a/modules/notification/mail/mail.go b/modules/notification/mail/mail.go index ee8a0c436c..f984ea7661 100644 --- a/modules/notification/mail/mail.go +++ b/modules/notification/mail/mail.go @@ -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) diff --git a/modules/notification/notification.go b/modules/notification/notification.go index 7ced57ce2d..d22d157bec 100644 --- a/modules/notification/notification.go +++ b/modules/notification/notification.go @@ -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 { diff --git a/modules/notification/ui/ui.go b/modules/notification/ui/ui.go index 8e510e9cd4..25ea4d91c6 100644 --- a/modules/notification/ui/ui.go +++ b/modules/notification/ui/ui.go @@ -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{ diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go index 07fa968d28..261d00fde8 100644 --- a/modules/structs/pull_review.go +++ b/modules/structs/pull_review.go @@ -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"` diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 987a6ad983..b8e4f5d505 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -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" } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a4b677e43b..767696cfb9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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] diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9c21107a28..85c4e4d5bf 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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"). diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go index d39db4c660..63179aa990 100644 --- a/routers/api/v1/repo/pull_review.go +++ b/routers/api/v1/repo/pull_review.go @@ -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) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 8919a969ec..a2dc2193a8 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -151,6 +151,9 @@ type swaggerParameterBodies struct { SubmitPullReviewOptions api.SubmitPullReviewOptions // in:body + DismissPullReviewOptions api.DismissPullReviewOptions + + // in:body MigrateRepoOptions api.MigrateRepoOptions // in:body diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 71c8f1efbb..fa1ee99771 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -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) { diff --git a/routers/repo/pull_review.go b/routers/repo/pull_review.go index df49b6cfe1..89e87ccc44 100644 --- a/routers/repo/pull_review.go +++ b/routers/repo/pull_review.go @@ -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())) +} diff --git a/routers/routes/web.go b/routers/routes/web.go index 9e3e690fb9..2f28e567f9 100644 --- a/routers/routes/web.go +++ b/routers/routes/web.go @@ -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) diff --git a/services/mailer/mail.go b/services/mailer/mail.go index b4217c0466..e87d34ab29 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -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: diff --git a/services/pull/review.go b/services/pull/review.go index 8994a9e78a..4e77e11daa 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -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 +} diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl index e062dca7f1..b7d576bef4 100644 --- a/templates/mail/issue/default.tmpl +++ b/templates/mail/issue/default.tmpl @@ -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 ""}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 63fe73857c..b971c6b1ae 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -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 }} @@ -415,6 +416,9 @@ {{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}} @@ -698,5 +702,44 @@ </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}} diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl index 34eaa83eb2..9e883c0a93 100644 --- a/templates/repo/issue/view_content/pull.tmpl +++ b/templates/repo/issue/view_content/pull.tmpl @@ -34,9 +34,36 @@ </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 diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 28aa617799..94493749af 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7761,6 +7761,124 @@ } } }, + "/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": [ @@ -13036,6 +13154,17 @@ }, "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", @@ -15199,6 +15328,10 @@ "type": "string", "x-go-name": "CommitID" }, + "dismissed": { + "type": "boolean", + "x-go-name": "Dismissed" + }, "html_url": { "type": "string", "x-go-name": "HTMLURL" diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index 744e028bc2..d25920a24e 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -78,6 +78,10 @@ {{ $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)}} @@ -111,6 +115,9 @@ <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> diff --git a/web_src/js/index.js b/web_src/js/index.js index f5f4841410..0d60c21cca 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -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; |