Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dismiss stale reviews per rule #463

Merged
merged 23 commits into from
Sep 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions policy/approval/approve.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,29 @@ func (r *Rule) Evaluate(ctx context.Context, prctx pull.Context) (res common.Res
}
}
res.PredicateResults = predicateResults
approved, msg, err := r.IsApproved(ctx, prctx)

allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
if err != nil {
res.Error = errors.Wrap(err, "failed to filter candidates")
return
}

approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i left this bit because i still thought it was a good idea for refactoring the tests, but let me know if you want me to revert this too.

if err != nil {
res.Error = errors.Wrap(err, "failed to compute approval status")
return
}

res.AllowedCandidates = allowedCandidates

res.StatusDescription = msg
if approved {
res.Status = common.StatusApproved
} else {
res.Status = common.StatusPending
res.ReviewRequestRule = r.getReviewRequestRule()
}

return
}

Expand All @@ -169,19 +179,14 @@ func (r *Rule) getReviewRequestRule() *common.ReviewRequestRule {
}
}

func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context) (bool, string, error) {
func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) (bool, string, error) {
log := zerolog.Ctx(ctx)

if r.Requires.Count <= 0 {
log.Debug().Msg("rule requires no approvals")
return true, "No approval required", nil
}

candidates, err := r.filteredCandidates(ctx, prctx)
if err != nil {
return false, "", err
}

log.Debug().Msgf("found %d candidates for approval", len(candidates))

// collect users "banned" by approval options
Expand Down Expand Up @@ -251,7 +256,7 @@ func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context) (bool, string
return false, msg, nil
}

func (r *Rule) filteredCandidates(ctx context.Context, prctx pull.Context) ([]*common.Candidate, error) {
func (r *Rule) FilteredCandidates(ctx context.Context, prctx pull.Context) ([]*common.Candidate, error) {
candidates, err := r.Options.GetMethods().Candidates(ctx, prctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get approval candidates")
Expand Down Expand Up @@ -285,15 +290,12 @@ func (r *Rule) filterEditedCandidates(ctx context.Context, prctx pull.Context, c

var allowedCandidates []*common.Candidate
for _, candidate := range candidates {
if r.Options.IgnoreEditedComments {
if candidate.LastEditedAt.IsZero() {
allowedCandidates = append(allowedCandidates, candidate)
}
if candidate.LastEditedAt.IsZero() {
allowedCandidates = append(allowedCandidates, candidate)
}
}

log.Debug().Msgf("discarded %d candidates with edited comments",
len(candidates)-len(allowedCandidates))
log.Debug().Msgf("discarded %d candidates with edited comments", len(candidates)-len(allowedCandidates))

return allowedCandidates, nil
}
Expand Down
10 changes: 8 additions & 2 deletions policy/approval/approve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ func TestIsApproved(t *testing.T) {
}

assertApproved := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
approved, msg, err := r.IsApproved(ctx, prctx)
allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
require.NoError(t, err)

approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
require.NoError(t, err)

if assert.True(t, approved, "pull request was not approved") {
Expand All @@ -144,7 +147,10 @@ func TestIsApproved(t *testing.T) {
}

assertPending := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
approved, msg, err := r.IsApproved(ctx, prctx)
allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
require.NoError(t, err)

approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
require.NoError(t, err)

if assert.False(t, approved, "pull request was incorrectly approved") {
Expand Down
14 changes: 14 additions & 0 deletions policy/common/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,16 @@ type Methods struct {
GithubReviewState pull.ReviewState `yaml:"-" json:"-"`
}

type CandidateType string

const (
ReviewCandidate CandidateType = "review"
CommentCandidate CandidateType = "comment"
)

type Candidate struct {
Type CandidateType
ReviewID string
User string
CreatedAt time.Time
LastEditedAt time.Time
Expand Down Expand Up @@ -64,6 +73,7 @@ func (m *Methods) Candidates(ctx context.Context, prctx pull.Context) ([]*Candid
for _, c := range comments {
if m.CommentMatches(c.Body) {
candidates = append(candidates, &Candidate{
Type: CommentCandidate,
User: c.Author,
CreatedAt: c.CreatedAt,
LastEditedAt: c.LastEditedAt,
Expand All @@ -83,13 +93,17 @@ func (m *Methods) Candidates(ctx context.Context, prctx pull.Context) ([]*Candid
if len(m.GithubReviewCommentPatterns) > 0 {
if m.githubReviewCommentMatches(r.Body) {
candidates = append(candidates, &Candidate{
Type: ReviewCandidate,
ReviewID: r.ID,
User: r.Author,
CreatedAt: r.CreatedAt,
LastEditedAt: r.LastEditedAt,
})
}
} else {
candidates = append(candidates, &Candidate{
Type: ReviewCandidate,
ReviewID: r.ID,
User: r.Author,
CreatedAt: r.CreatedAt,
LastEditedAt: r.LastEditedAt,
Expand Down
1 change: 1 addition & 0 deletions policy/common/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type Result struct {
Requires Actors

ReviewRequestRule *ReviewRequestRule
AllowedCandidates []*Candidate

Children []*Result
}
1 change: 1 addition & 0 deletions pull/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const (
)

type Review struct {
ID string
CreatedAt time.Time
LastEditedAt time.Time
Author string
Expand Down
2 changes: 2 additions & 0 deletions pull/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ func (pi v4PageInfo) UpdateCursor(vars map[string]interface{}, name string) bool
}

type v4PullRequestReview struct {
ID string
Author v4Actor
State string
Body string
Expand All @@ -1004,6 +1005,7 @@ func (r *v4PullRequestReview) ToReview() *Review {
}

return &Review{
ID: r.ID,
CreatedAt: r.SubmittedAt,
LastEditedAt: r.LastEditedAt,
Author: r.Author.GetV3Login(),
Expand Down
117 changes: 116 additions & 1 deletion server/handler/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/palantir/policy-bot/pull"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/shurcooL/githubv4"
)

const (
Expand Down Expand Up @@ -186,7 +187,17 @@ func (b *Base) Evaluate(ctx context.Context, installationID int64, trigger commo
return err
}

return b.RequestReviewsForResult(ctx, prctx, client, trigger, result)
err = b.RequestReviewsForResult(ctx, prctx, client, trigger, result)
if err != nil {
return err
}

err = b.dismissStaleReviewsForResult(ctx, prctx, v4client, result)
if err != nil {
return err
}

return nil
}

func (b *Base) ValidateFetchedConfig(ctx context.Context, prctx pull.Context, client *github.Client, fc FetchedConfig, trigger common.Trigger) (common.Evaluator, error) {
Expand Down Expand Up @@ -391,6 +402,110 @@ func selectionToReviewersRequest(s reviewer.Selection) github.ReviewersRequest {
return req
}

func (b *Base) findResultsWithAllowedCandidates(result *common.Result) []*common.Result {
var results []*common.Result
for _, c := range result.Children {
results = append(results, b.findResultsWithAllowedCandidates(c)...)
}

if len(result.Children) == 0 && len(result.AllowedCandidates) > 0 && result.Error == nil {
results = append(results, result)
}

return results
}

func (b *Base) reviewIsAllowed(review *pull.Review, allowedCandidates []*common.Candidate) bool {
for _, candidate := range allowedCandidates {
if review.ID == candidate.ReviewID {
return true
}
}

return false
}

// We already know that these are discarded review candidates for one of three reasons
// so first we check for edited and then we check to see if its a review thats at least
// 5 seconds old and we know that it was invalidated by a new commit, and then finally
// if it was missing a github review comment pattern that was required.
//
// This is brittle and may need refactoring in future versions because it assumes the bot
// will take less than 5 seconds to respond, but thought that having a dismissal reason
// was valuable.
func (b *Base) reasonForDismissedReview(review *pull.Review) string {
if !review.LastEditedAt.IsZero() {
return "was edited"
}

if review.CreatedAt.Before(time.Now().Add(-5 * time.Second)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the assumption here is that if the review was submitted or edited (and doesn't match a pattern), the bot is responding to that event within 5 seconds, whereas it's unlikely that someone will submit a review and then a new commit will be pushed within 5 seconds?

I'm always hesitant about fixed time ranges like this, but as a heuristic for generating informational text, it's probably fine. An alternative might be to also pass the push time of the most recent commit in the Result struct and compare against that, but I'm not sure it is worth the effort.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea it feels brittle to me but works.. I think it's hard to grok because it requires so much knowledge about the filtering.. we already know that these are discarded review candidates for one of three reasons so first we check for edited and then we check to see if its a review thats at least 5 seconds old and we know that it was invalidated by a new commit, and then the finally is that it didn't include a github review comment pattern that was also required.

but yes this assumes the bot is responding within those 5 seconds, which seemed fine in my brief testing, but also still small enough to be unlikely that someone would push a new commit in that time frame. but yes still feels brittle, so i was hoping you would have a better solution. i'll try this!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding some comments on the function or next to each condition about what it's checking for and why this works might help decipher the logic for readers in the future too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried all the approaches i could think of including your Result suggestion and nothing really felt any better, so just added a comment explaining the function as much as i could.

return "was invalidated by another commit"
}

return "didn't include a valid comment pattern"
}

func (b *Base) dismissStaleReviewsForResult(ctx context.Context, prctx pull.Context, v4client *githubv4.Client, result common.Result) error {
logger := zerolog.Ctx(ctx)

var allowedCandidates []*common.Candidate

results := b.findResultsWithAllowedCandidates(&result)
for _, res := range results {
for _, candidate := range res.AllowedCandidates {
if candidate.Type != common.ReviewCandidate {
continue
}
allowedCandidates = append(allowedCandidates, candidate)
}
}

reviews, err := prctx.Reviews()
if err != nil {
return err
}

for _, r := range reviews {
if r.State != pull.ReviewApproved {
continue
}

if b.reviewIsAllowed(r, allowedCandidates) {
continue
}

reason := b.reasonForDismissedReview(r)
message := fmt.Sprintf("Dismissed because the approval %s", reason)
logger.Info().Msgf("Dismissing stale review %s because it %s", r.ID, reason)
err := b.dismissPullRequestReview(ctx, v4client, r.ID, message)
if err != nil {
logger.Err(err).Msg("Failed to dismiss stale review")
return err
}
}

return nil
}

func (b *Base) dismissPullRequestReview(ctx context.Context, v4client *githubv4.Client, reviewID string, message string) error {
var m struct {
DismissPullRequestReview struct {
ClientMutationID *githubv4.String
} `graphql:"dismissPullRequestReview(input: $input)"`
}

input := githubv4.DismissPullRequestReviewInput{
PullRequestReviewID: githubv4.String(reviewID),
Message: githubv4.String(message),
}

if err := v4client.Mutate(ctx, &m, input, nil); err != nil {
return errors.Wrapf(err, "failed to dismiss pull request review %s", reviewID)
}

return nil
}

func setStringFromEnv(key, prefix string, value *string) bool {
if v, ok := os.LookupEnv(prefix + key); ok {
*value = v
Expand Down