mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-10-31 22:58:59 +01:00
Prevent re-review and dismiss review actions on closed and merged PRs (#30065)
Resolves #29965. --- Manually tested this by: - Following the [installation](https://docs.gitea.com/next/installation/install-with-docker#basics) guide (but built a local Docker image instead) - Creating 2 users, one who is the `Owner` of a newly-created repository and the other a `Collaborator` - Had the `Collaborator` create a PR that the `Owner` reviews - `Collaborator` resolves conversation and `Owner` merges PR And with this change we see that we can no longer see re-request review button for the `Owner`: <img width="1351" alt="Screenshot 2024-03-25 at 12 39 18 AM" src="https://github.com/go-gitea/gitea/assets/60799661/bcd9c579-3cf7-474f-a51e-b436fe1a39a4"> (cherry picked from commit 242b331260925e604150346e61329097d5731e77)
This commit is contained in:
parent
3ccb0c2512
commit
23676bfea7
9 changed files with 170 additions and 11 deletions
|
@ -66,6 +66,23 @@ func (err ErrNotValidReviewRequest) Unwrap() error {
|
||||||
return util.ErrInvalidArgument
|
return util.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR.
|
||||||
|
type ErrReviewRequestOnClosedPR struct{}
|
||||||
|
|
||||||
|
// IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR.
|
||||||
|
func IsErrReviewRequestOnClosedPR(err error) bool {
|
||||||
|
_, ok := err.(ErrReviewRequestOnClosedPR)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrReviewRequestOnClosedPR) Error() string {
|
||||||
|
return "cannot request a re-review on a closed or merged PR"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrReviewRequestOnClosedPR) Unwrap() error {
|
||||||
|
return util.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
// ReviewType defines the sort of feedback a review gives
|
// ReviewType defines the sort of feedback a review gives
|
||||||
type ReviewType int
|
type ReviewType int
|
||||||
|
|
||||||
|
@ -618,11 +635,26 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if review != nil {
|
||||||
// skip it when reviewer hase been request to review
|
// skip it when reviewer hase been request to review
|
||||||
if review != nil && review.Type == ReviewTypeRequest {
|
if review.Type == ReviewTypeRequest {
|
||||||
return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction.
|
return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if issue.IsClosed {
|
||||||
|
return nil, ErrReviewRequestOnClosedPR{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.IsPull {
|
||||||
|
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if issue.PullRequest.HasMerged {
|
||||||
|
return nil, ErrReviewRequestOnClosedPR{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if the reviewer is an official reviewer,
|
// if the reviewer is an official reviewer,
|
||||||
// remove the official flag in the all previous reviews
|
// remove the official flag in the all previous reviews
|
||||||
official, err := IsOfficialReviewer(ctx, issue, reviewer)
|
official, err := IsOfficialReviewer(ctx, issue, reviewer)
|
||||||
|
|
|
@ -288,3 +288,33 @@ func TestDeleteDismissedReview(t *testing.T) {
|
||||||
assert.NoError(t, issues_model.DeleteReview(db.DefaultContext, review))
|
assert.NoError(t, issues_model.DeleteReview(db.DefaultContext, review))
|
||||||
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
|
unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddReviewRequest(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1})
|
||||||
|
assert.NoError(t, pull.LoadIssue(db.DefaultContext))
|
||||||
|
issue := pull.Issue
|
||||||
|
assert.NoError(t, issue.LoadRepo(db.DefaultContext))
|
||||||
|
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
_, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{
|
||||||
|
Issue: issue,
|
||||||
|
Reviewer: reviewer,
|
||||||
|
Type: issues_model.ReviewTypeReject,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pull.HasMerged = false
|
||||||
|
assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged"))
|
||||||
|
issue.IsClosed = true
|
||||||
|
_, err = issues_model.AddReviewRequest(db.DefaultContext, issue, reviewer, &user_model.User{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
|
||||||
|
|
||||||
|
pull.HasMerged = true
|
||||||
|
assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged"))
|
||||||
|
issue.IsClosed = false
|
||||||
|
_, err = issues_model.AddReviewRequest(db.DefaultContext, issue, reviewer, &user_model.User{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
|
||||||
|
}
|
||||||
|
|
|
@ -787,6 +787,8 @@ func DeleteReviewRequests(ctx *context.APIContext) {
|
||||||
// "$ref": "#/responses/empty"
|
// "$ref": "#/responses/empty"
|
||||||
// "422":
|
// "422":
|
||||||
// "$ref": "#/responses/validationError"
|
// "$ref": "#/responses/validationError"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
opts := web.GetForm(ctx).(*api.PullReviewRequestOptions)
|
opts := web.GetForm(ctx).(*api.PullReviewRequestOptions)
|
||||||
|
@ -855,6 +857,10 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
|
||||||
for _, reviewer := range reviewers {
|
for _, reviewer := range reviewers {
|
||||||
comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd)
|
comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if issues_model.IsErrReviewRequestOnClosedPR(err) {
|
||||||
|
ctx.Error(http.StatusForbidden, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
|
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1068,7 +1074,7 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors
|
||||||
ctx.Error(http.StatusForbidden, "", "Must be repo admin")
|
ctx.Error(http.StatusForbidden, "", "Must be repo admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
review, pr, isWrong := prepareSingleReview(ctx)
|
review, _, isWrong := prepareSingleReview(ctx)
|
||||||
if isWrong {
|
if isWrong {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -1078,13 +1084,12 @@ func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors
|
||||||
return
|
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(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors)
|
_, err := pull_service.DismissReview(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if pull_service.IsErrDismissRequestOnClosedPR(err) {
|
||||||
|
ctx.Error(http.StatusForbidden, "", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err)
|
ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -2494,6 +2494,10 @@ func UpdatePullReviewRequest(ctx *context.Context) {
|
||||||
|
|
||||||
_, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach")
|
_, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if issues_model.IsErrReviewRequestOnClosedPR(err) {
|
||||||
|
ctx.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("ReviewRequest", err)
|
ctx.ServerError("ReviewRequest", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -263,6 +263,10 @@ func DismissReview(ctx *context.Context) {
|
||||||
form := web.GetForm(ctx).(*forms.DismissReviewForm)
|
form := web.GetForm(ctx).(*forms.DismissReviewForm)
|
||||||
comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true)
|
comm, err := pull_service.DismissReview(ctx, form.ReviewID, ctx.Repo.Repository.ID, form.Message, ctx.Doer, true, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if pull_service.IsErrDismissRequestOnClosedPR(err) {
|
||||||
|
ctx.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ServerError("pull_service.DismissReview", err)
|
ctx.ServerError("pull_service.DismissReview", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,29 @@ import (
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
|
var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`)
|
||||||
|
|
||||||
|
// ErrDismissRequestOnClosedPR represents an error when an user tries to dismiss a review associated to a closed or merged PR.
|
||||||
|
type ErrDismissRequestOnClosedPR struct{}
|
||||||
|
|
||||||
|
// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR.
|
||||||
|
func IsErrDismissRequestOnClosedPR(err error) bool {
|
||||||
|
_, ok := err.(ErrDismissRequestOnClosedPR)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrDismissRequestOnClosedPR) Error() string {
|
||||||
|
return "can't dismiss a review associated to a closed or merged PR"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrDismissRequestOnClosedPR) Unwrap() error {
|
||||||
|
return util.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
// checkInvalidation checks if the line of code comment got changed by another commit.
|
// checkInvalidation checks if the line of code comment got changed by another commit.
|
||||||
// If the line got changed the comment is going to be invalidated.
|
// If the line got changed the comment is going to be invalidated.
|
||||||
func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error {
|
func checkInvalidation(ctx context.Context, c *issues_model.Comment, doer *user_model.User, repo *git.Repository, branch string) error {
|
||||||
|
@ -382,6 +400,21 @@ func DismissReview(ctx context.Context, reviewID, repoID int64, message string,
|
||||||
return nil, fmt.Errorf("reviews's repository is not the same as the one we expect")
|
return nil, fmt.Errorf("reviews's repository is not the same as the one we expect")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issue := review.Issue
|
||||||
|
|
||||||
|
if issue.IsClosed {
|
||||||
|
return nil, ErrDismissRequestOnClosedPR{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.IsPull {
|
||||||
|
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if issue.PullRequest.HasMerged {
|
||||||
|
return nil, ErrDismissRequestOnClosedPR{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil {
|
if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
48
services/pull/review_test.go
Normal file
48
services/pull/review_test.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pull_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
pull_service "code.gitea.io/gitea/services/pull"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDismissReview(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{})
|
||||||
|
assert.NoError(t, pull.LoadIssue(db.DefaultContext))
|
||||||
|
issue := pull.Issue
|
||||||
|
assert.NoError(t, issue.LoadRepo(db.DefaultContext))
|
||||||
|
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
review, err := issues_model.CreateReview(db.DefaultContext, issues_model.CreateReviewOptions{
|
||||||
|
Issue: issue,
|
||||||
|
Reviewer: reviewer,
|
||||||
|
Type: issues_model.ReviewTypeReject,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
issue.IsClosed = true
|
||||||
|
pull.HasMerged = false
|
||||||
|
assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed"))
|
||||||
|
assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged"))
|
||||||
|
_, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err))
|
||||||
|
|
||||||
|
pull.HasMerged = true
|
||||||
|
pull.Issue.IsClosed = false
|
||||||
|
assert.NoError(t, issues_model.UpdateIssueCols(db.DefaultContext, issue, "is_closed"))
|
||||||
|
assert.NoError(t, pull.UpdateCols(db.DefaultContext, "has_merged"))
|
||||||
|
_, err = pull_service.DismissReview(db.DefaultContext, review.ID, issue.RepoID, "", &user_model.User{}, false, false)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err))
|
||||||
|
}
|
|
@ -59,7 +59,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-flex tw-items-center tw-gap-2">
|
<div class="tw-flex tw-items-center tw-gap-2">
|
||||||
{{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed))}}
|
{{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged))}}
|
||||||
<a href="#" class="ui muted icon tw-flex tw-items-center show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}" data-modal="#dismiss-review-modal-{{.Review.ID}}">
|
<a href="#" class="ui muted icon tw-flex tw-items-center show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}" data-modal="#dismiss-review-modal-{{.Review.ID}}">
|
||||||
{{svg "octicon-x" 20}}
|
{{svg "octicon-x" 20}}
|
||||||
</a>
|
</a>
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
{{svg "octicon-hourglass" 16}}
|
{{svg "octicon-hourglass" 16}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanChange}}
|
{{if and .CanChange (or .Checked (and (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged)))}}
|
||||||
<a href="#" class="ui muted icon re-request-review{{if .Checked}} checked{{end}}" data-tooltip-content="{{if .Checked}}{{ctx.Locale.Tr "repo.issues.remove_request_review"}}{{else}}{{ctx.Locale.Tr "repo.issues.re_request_review"}}{{end}}" data-issue-id="{{$.Issue.ID}}" data-id="{{.ItemID}}" data-update-url="{{$.RepoLink}}/issues/request_review">{{if .Checked}}{{svg "octicon-trash"}}{{else}}{{svg "octicon-sync"}}{{end}}</a>
|
<a href="#" class="ui muted icon re-request-review{{if .Checked}} checked{{end}}" data-tooltip-content="{{if .Checked}}{{ctx.Locale.Tr "repo.issues.remove_request_review"}}{{else}}{{ctx.Locale.Tr "repo.issues.re_request_review"}}{{end}}" data-issue-id="{{$.Issue.ID}}" data-id="{{.ItemID}}" data-update-url="{{$.RepoLink}}/issues/request_review">{{if .Checked}}{{svg "octicon-trash"}}{{else}}{{svg "octicon-sync"}}{{end}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}}
|
{{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}}
|
||||||
|
|
3
templates/swagger/v1_json.tmpl
generated
3
templates/swagger/v1_json.tmpl
generated
|
@ -11422,6 +11422,9 @@
|
||||||
"204": {
|
"204": {
|
||||||
"$ref": "#/responses/empty"
|
"$ref": "#/responses/empty"
|
||||||
},
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"$ref": "#/responses/notFound"
|
"$ref": "#/responses/notFound"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue