Skip to content

Commit ab4bc76

Browse files
authored
Improve review dismissal behavior (#577)
Existing dismissal logic compares all reviews on the PR to the set of reviews that might satisfy any rule. This does not work well when policies combine rules that invalidate on push with rules that do not. In this situation, reviews are not dismissed when new commits are pushed because they might satisfy the rules that do not invalidate. Instead, a user's review is dismissed when they submit a second review. This is because we only consider the most recent review from a user, which means their previous review doesn't count for any rules and is eligible to be dismissed. We chose this approach to try to dismiss reviews that did not match required comment patterns, but ended up removing that before merging the feature. This commit switches back to the original approach, which is to collect specific reviews to dismiss, rather than trying to infer what is safe to dismiss. This should be more reliable and is easier to reason about.
1 parent 793946e commit ab4bc76

File tree

4 files changed

+141
-130
lines changed

4 files changed

+141
-130
lines changed

policy/approval/approve.go

Lines changed: 80 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -132,21 +132,22 @@ func (r *Rule) Evaluate(ctx context.Context, prctx pull.Context) (res common.Res
132132
}
133133
res.PredicateResults = predicateResults
134134

135-
allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
135+
candidates, dismissals, err := r.FilteredCandidates(ctx, prctx)
136136
if err != nil {
137137
res.Error = errors.Wrap(err, "failed to filter candidates")
138138
return
139139
}
140140

141-
approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
141+
approved, approvers, err := r.IsApproved(ctx, prctx, candidates)
142142
if err != nil {
143143
res.Error = errors.Wrap(err, "failed to compute approval status")
144144
return
145145
}
146146

147-
res.AllowedCandidates = allowedCandidates
147+
res.Approvers = approvers
148+
res.Dismissals = dismissals
149+
res.StatusDescription = r.statusDescription(approved, approvers, candidates)
148150

149-
res.StatusDescription = msg
150151
if approved {
151152
res.Status = common.StatusApproved
152153
} else {
@@ -177,12 +178,12 @@ func (r *Rule) getReviewRequestRule() *common.ReviewRequestRule {
177178
}
178179
}
179180

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

183184
if r.Requires.Count <= 0 {
184185
log.Debug().Msg("rule requires no approvals")
185-
return true, "No approval required", nil
186+
return true, nil, nil
186187
}
187188

188189
log.Debug().Msgf("found %d candidates for approval", len(candidates))
@@ -202,7 +203,7 @@ func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context, candidates []
202203
if !r.Options.AllowContributor && !r.Options.AllowNonAuthorContributor {
203204
commits, err := r.filteredCommits(ctx, prctx)
204205
if err != nil {
205-
return false, "", err
206+
return false, nil, err
206207
}
207208

208209
for _, c := range commits {
@@ -215,7 +216,7 @@ func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context, candidates []
215216
}
216217

217218
// filter real approvers using banned status and required membership
218-
var approvers []string
219+
var approvers []*common.Candidate
219220
for _, c := range candidates {
220221
if banned[c.User] {
221222
log.Debug().Str("user", c.User).Msg("rejecting approval by banned user")
@@ -224,113 +225,118 @@ func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context, candidates []
224225

225226
isApprover, err := r.Requires.Actors.IsActor(ctx, prctx, c.User)
226227
if err != nil {
227-
return false, "", errors.Wrap(err, "failed to check candidate status")
228+
return false, nil, errors.Wrap(err, "failed to check candidate status")
228229
}
229230
if !isApprover {
230-
log.Debug().Str("user", c.User).Msg("ignoring approval by non-whitelisted user")
231+
log.Debug().Str("user", c.User).Msg("ignoring approval by non-required user")
231232
continue
232233
}
233234

234-
approvers = append(approvers, c.User)
235+
approvers = append(approvers, c)
235236
}
236237

237238
log.Debug().Msgf("found %d/%d required approvers", len(approvers), r.Requires.Count)
238-
remaining := r.Requires.Count - len(approvers)
239-
240-
if remaining <= 0 {
241-
msg := fmt.Sprintf("Approved by %s", strings.Join(approvers, ", "))
242-
return true, msg, nil
243-
}
244-
245-
if len(candidates) > 0 && len(approvers) == 0 {
246-
msg := fmt.Sprintf("%d/%d approvals required. Ignored %s from disqualified users",
247-
len(approvers),
248-
r.Requires.Count,
249-
numberOfApprovals(len(candidates)))
250-
return false, msg, nil
251-
}
252-
253-
msg := fmt.Sprintf("%d/%d approvals required", len(approvers), r.Requires.Count)
254-
return false, msg, nil
239+
return len(approvers) >= r.Requires.Count, approvers, nil
255240
}
256241

257-
func (r *Rule) FilteredCandidates(ctx context.Context, prctx pull.Context) ([]*common.Candidate, error) {
242+
// FilteredCandidates returns the potential approval candidates and any
243+
// candidates that should be dimissed due to rule options.
244+
func (r *Rule) FilteredCandidates(ctx context.Context, prctx pull.Context) ([]*common.Candidate, []*common.Dismissal, error) {
245+
258246
candidates, err := r.Options.GetMethods().Candidates(ctx, prctx)
259247
if err != nil {
260-
return nil, errors.Wrap(err, "failed to get approval candidates")
248+
return nil, nil, errors.Wrap(err, "failed to get approval candidates")
261249
}
262250

263251
sort.Stable(common.CandidatesByCreationTime(candidates))
264252

253+
var editDismissals []*common.Dismissal
265254
if r.Options.IgnoreEditedComments {
266-
candidates, err = r.filterEditedCandidates(ctx, prctx, candidates)
255+
candidates, editDismissals, err = r.filterEditedCandidates(ctx, prctx, candidates)
267256
if err != nil {
268-
return nil, err
257+
return nil, nil, err
269258
}
270259
}
271260

261+
var pushDismissals []*common.Dismissal
272262
if r.Options.InvalidateOnPush {
273-
candidates, err = r.filterInvalidCandidates(ctx, prctx, candidates)
263+
candidates, pushDismissals, err = r.filterInvalidCandidates(ctx, prctx, candidates)
274264
if err != nil {
275-
return nil, err
265+
return nil, nil, err
276266
}
277267
}
278268

279-
return candidates, nil
269+
var dismissals []*common.Dismissal
270+
dismissals = append(dismissals, editDismissals...)
271+
dismissals = append(dismissals, pushDismissals...)
272+
273+
return candidates, dismissals, nil
280274
}
281275

282-
func (r *Rule) filterEditedCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, error) {
276+
func (r *Rule) filterEditedCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, []*common.Dismissal, error) {
283277
log := zerolog.Ctx(ctx)
284278

285279
if !r.Options.IgnoreEditedComments {
286-
return candidates, nil
280+
return candidates, nil, nil
287281
}
288282

289-
var allowedCandidates []*common.Candidate
290-
for _, candidate := range candidates {
291-
if candidate.LastEditedAt.IsZero() {
292-
allowedCandidates = append(allowedCandidates, candidate)
283+
var allowed []*common.Candidate
284+
var dismissed []*common.Dismissal
285+
for _, c := range candidates {
286+
if c.LastEditedAt.IsZero() {
287+
allowed = append(allowed, c)
288+
} else {
289+
dismissed = append(dismissed, &common.Dismissal{
290+
Candidate: c,
291+
Reason: "Comment was edited",
292+
})
293293
}
294294
}
295295

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

298-
return allowedCandidates, nil
298+
return allowed, dismissed, nil
299299
}
300300

301-
func (r *Rule) filterInvalidCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, error) {
301+
func (r *Rule) filterInvalidCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, []*common.Dismissal, error) {
302302
log := zerolog.Ctx(ctx)
303303

304304
if !r.Options.InvalidateOnPush {
305-
return candidates, nil
305+
return candidates, nil, nil
306306
}
307307

308308
commits, err := r.filteredCommits(ctx, prctx)
309309
if err != nil {
310-
return nil, err
310+
return nil, nil, err
311311
}
312312
if len(commits) == 0 {
313-
return candidates, nil
313+
return candidates, nil, nil
314314
}
315315

316316
last := findLastPushed(commits)
317317
if last == nil {
318-
return nil, errors.New("no commit contained a push date")
318+
return nil, nil, errors.New("no commit contained a push date")
319319
}
320320

321-
var allowedCandidates []*common.Candidate
322-
for _, candidate := range candidates {
323-
if candidate.CreatedAt.After(*last.PushedAt) {
324-
allowedCandidates = append(allowedCandidates, candidate)
321+
var allowed []*common.Candidate
322+
var dismissed []*common.Dismissal
323+
for _, c := range candidates {
324+
if c.CreatedAt.After(*last.PushedAt) {
325+
allowed = append(allowed, c)
326+
} else {
327+
dismissed = append(dismissed, &common.Dismissal{
328+
Candidate: c,
329+
Reason: fmt.Sprintf("Invalidated by push of %.7s", last.SHA),
330+
})
325331
}
326332
}
327333

328334
log.Debug().Msgf("discarded %d candidates invalidated by push of %s at %s",
329-
len(candidates)-len(allowedCandidates),
335+
len(dismissed),
330336
last.SHA,
331337
last.PushedAt.Format(time.RFC3339))
332338

333-
return allowedCandidates, nil
339+
return allowed, dismissed, nil
334340
}
335341

336342
func (r *Rule) filteredCommits(ctx context.Context, prctx pull.Context) ([]*pull.Commit, error) {
@@ -369,6 +375,26 @@ func (r *Rule) filteredCommits(ctx context.Context, prctx pull.Context) ([]*pull
369375
return filtered, nil
370376
}
371377

378+
func (r *Rule) statusDescription(approved bool, approvers, candidates []*common.Candidate) string {
379+
if approved {
380+
if len(approvers) == 0 {
381+
return "No approval required"
382+
}
383+
384+
var names []string
385+
for _, c := range approvers {
386+
names = append(names, c.User)
387+
}
388+
return fmt.Sprintf("Approved by %s", strings.Join(names, ", "))
389+
}
390+
391+
desc := fmt.Sprintf("%d/%d required approvals", len(approvers), r.Requires.Count)
392+
if disqualified := len(candidates) - len(approvers); disqualified > 0 {
393+
desc += fmt.Sprintf(". Ignored %s from disqualified users", numberOfApprovals(disqualified))
394+
}
395+
return desc
396+
}
397+
372398
func isUpdateMerge(commits []*pull.Commit, c *pull.Commit) bool {
373399
// must be a simple merge commit (exactly 2 parents)
374400
if len(c.Parents) != 2 {

policy/approval/approve_test.go

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -141,25 +141,27 @@ func TestIsApproved(t *testing.T) {
141141
}
142142

143143
assertApproved := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
144-
allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
144+
allowedCandidates, _, err := r.FilteredCandidates(ctx, prctx)
145145
require.NoError(t, err)
146146

147-
approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
147+
approved, approvers, err := r.IsApproved(ctx, prctx, allowedCandidates)
148148
require.NoError(t, err)
149149

150150
if assert.True(t, approved, "pull request was not approved") {
151+
msg := r.statusDescription(approved, approvers, allowedCandidates)
151152
assert.Equal(t, expected, msg)
152153
}
153154
}
154155

155156
assertPending := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
156-
allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
157+
allowedCandidates, _, err := r.FilteredCandidates(ctx, prctx)
157158
require.NoError(t, err)
158159

159-
approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
160+
approved, approvers, err := r.IsApproved(ctx, prctx, allowedCandidates)
160161
require.NoError(t, err)
161162

162163
if assert.False(t, approved, "pull request was incorrectly approved") {
164+
msg := r.statusDescription(approved, approvers, allowedCandidates)
163165
assert.Equal(t, expected, msg)
164166
}
165167
}
@@ -177,7 +179,7 @@ func TestIsApproved(t *testing.T) {
177179
Count: 1,
178180
},
179181
}
180-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
182+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 7 approvals from disqualified users")
181183
})
182184

183185
t.Run("authorCannotApprove", func(t *testing.T) {
@@ -315,7 +317,7 @@ func TestIsApproved(t *testing.T) {
315317
},
316318
},
317319
}
318-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
320+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 7 approvals from disqualified users")
319321
})
320322

321323
t.Run("specificOrgApproves", func(t *testing.T) {
@@ -338,7 +340,7 @@ func TestIsApproved(t *testing.T) {
338340
},
339341
},
340342
}
341-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
343+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 7 approvals from disqualified users")
342344
})
343345

344346
t.Run("specificOrgsOrUserApproves", func(t *testing.T) {
@@ -377,7 +379,7 @@ func TestIsApproved(t *testing.T) {
377379
assertApproved(t, prctx, r, "Approved by comment-approver")
378380

379381
r.Options.InvalidateOnPush = true
380-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 6 approvals from disqualified users")
382+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 6 approvals from disqualified users")
381383
})
382384

383385
t.Run("invalidateReviewOnPush", func(t *testing.T) {
@@ -402,7 +404,7 @@ func TestIsApproved(t *testing.T) {
402404
assertApproved(t, prctx, r, "Approved by review-approver")
403405

404406
r.Options.InvalidateOnPush = true
405-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 1 approval from disqualified users")
407+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 1 approval from disqualified users")
406408
})
407409

408410
t.Run("ignoreUpdateMergeAfterReview", func(t *testing.T) {
@@ -429,7 +431,7 @@ func TestIsApproved(t *testing.T) {
429431
InvalidateOnPush: true,
430432
},
431433
}
432-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 6 approvals from disqualified users")
434+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 6 approvals from disqualified users")
433435

434436
r.Options.IgnoreUpdateMerges = true
435437
assertApproved(t, prctx, r, "Approved by comment-approver")
@@ -461,7 +463,7 @@ func TestIsApproved(t *testing.T) {
461463
},
462464
},
463465
}
464-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 8 approvals from disqualified users")
466+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 8 approvals from disqualified users")
465467

466468
r.Options.IgnoreUpdateMerges = true
467469
assertApproved(t, prctx, r, "Approved by merge-committer")
@@ -483,7 +485,7 @@ func TestIsApproved(t *testing.T) {
483485
},
484486
},
485487
}
486-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
488+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 7 approvals from disqualified users")
487489

488490
r.Options.IgnoreCommitsBy = common.Actors{
489491
Users: []string{"comment-approver"},
@@ -507,7 +509,7 @@ func TestIsApproved(t *testing.T) {
507509
},
508510
},
509511
}
510-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
512+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 7 approvals from disqualified users")
511513
})
512514

513515
t.Run("ignoreCommitsInvalidateOnPush", func(t *testing.T) {
@@ -554,7 +556,7 @@ func TestIsApproved(t *testing.T) {
554556

555557
r.Options.IgnoreEditedComments = true
556558

557-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
559+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 5 approvals from disqualified users")
558560
})
559561

560562
t.Run("ignoreEditedComments", func(t *testing.T) {
@@ -573,7 +575,7 @@ func TestIsApproved(t *testing.T) {
573575

574576
r.Options.IgnoreEditedComments = true
575577

576-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
578+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 5 approvals from disqualified users")
577579
})
578580

579581
t.Run("ignoreEditedCommentsWithBodyPattern", func(t *testing.T) {
@@ -600,7 +602,7 @@ func TestIsApproved(t *testing.T) {
600602

601603
r.Options.IgnoreEditedComments = true
602604

603-
assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
605+
assertPending(t, prctx, r, "0/1 required approvals. Ignored 5 approvals from disqualified users")
604606
})
605607
}
606608

0 commit comments

Comments
 (0)