1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package approval
16
17 import (
18 "context"
19 "os"
20 "regexp"
21 "testing"
22 "time"
23
24 "github.com/rs/zerolog"
25 "github.com/stretchr/testify/assert"
26 "github.com/stretchr/testify/require"
27
28 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/pull"
29 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/pull/pulltest"
30
31 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/common"
32 )
33
34 func TestIsApproved(t *testing.T) {
35 logger := zerolog.New(os.Stdout)
36 ctx := logger.WithContext(context.Background())
37
38 now := time.Now()
39 basePullContext := func() *pulltest.Context {
40 return &pulltest.Context{
41 AuthorValue: "mhaypenny",
42 BodyValue: &pull.Body{
43 Body: "/no-platform",
44 CreatedAt: now.Add(10 * time.Second),
45 LastEditedAt: now.Add(20 * time.Second),
46 Author: "body-editor",
47 },
48 CommentsValue: []*pull.Comment{
49 {
50 CreatedAt: now.Add(10 * time.Second),
51 LastEditedAt: time.Time{},
52 Author: "other-user",
53 Body: "Why did you do this?",
54 },
55 {
56 CreatedAt: now.Add(20 * time.Second),
57 LastEditedAt: time.Time{},
58 Author: "comment-approver",
59 Body: "LGTM :+1: :shipit:",
60 },
61 {
62 CreatedAt: now.Add(30 * time.Second),
63 LastEditedAt: time.Time{},
64 Author: "disapprover",
65 Body: "I don't like things! :-1:",
66 },
67 {
68 CreatedAt: now.Add(40 * time.Second),
69 LastEditedAt: time.Time{},
70 Author: "mhaypenny",
71 Body: ":+1: my stuff is cool",
72 },
73 {
74 CreatedAt: now.Add(50 * time.Second),
75 LastEditedAt: time.Time{},
76 Author: "contributor-author",
77 Body: ":+1: I added to this PR",
78 },
79 {
80 CreatedAt: now.Add(60 * time.Second),
81 LastEditedAt: time.Time{},
82 Author: "contributor-committer",
83 Body: ":+1: I also added to this PR",
84 },
85 {
86 CreatedAt: now.Add(70 * time.Second),
87 LastEditedAt: now.Add(71 * time.Second),
88 Author: "comment-editor",
89 Body: "LGTM :+1: :shipit:",
90 },
91 },
92 ReviewsValue: []*pull.Review{
93 {
94 CreatedAt: now.Add(70 * time.Second),
95 LastEditedAt: time.Time{},
96 Author: "disapprover",
97 State: pull.ReviewChangesRequested,
98 Body: "I _really_ don't like things!",
99 },
100 {
101 CreatedAt: now.Add(80 * time.Second),
102 LastEditedAt: time.Time{},
103 Author: "review-approver",
104 State: pull.ReviewApproved,
105 Body: "I LIKE THIS",
106 },
107 {
108 CreatedAt: now.Add(90 * time.Second),
109 LastEditedAt: now.Add(91 * time.Second),
110 Author: "review-comment-editor",
111 State: pull.ReviewApproved,
112 Body: "I LIKE THIS",
113 },
114 },
115 CommitsValue: []*pull.Commit{
116 {
117 PushedAt: newTime(now.Add(5 * time.Second)),
118 SHA: "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
119 Author: "mhaypenny",
120 Committer: "mhaypenny",
121 },
122 {
123 PushedAt: newTime(now.Add(15 * time.Second)),
124 SHA: "674832587eaaf416371b30f5bc5a47e377f534ec",
125 Author: "contributor-author",
126 Committer: "mhaypenny",
127 },
128 {
129 PushedAt: newTime(now.Add(45 * time.Second)),
130 SHA: "97d5ea26da319a987d80f6db0b7ef759f2f2e441",
131 Author: "mhaypenny",
132 Committer: "contributor-committer",
133 },
134 },
135 OrgMemberships: map[string][]string{
136 "mhaypenny": {"everyone"},
137 "contributor-author": {"everyone"},
138 "contributor-committer": {"everyone"},
139 "comment-approver": {"everyone", "cool-org"},
140 "review-approver": {"everyone", "even-cooler-org"},
141 },
142 }
143 }
144
145 assertApproved := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
146 allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
147 require.NoError(t, err)
148
149 approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
150 require.NoError(t, err)
151
152 if assert.True(t, approved, "pull request was not approved") {
153 assert.Equal(t, expected, msg)
154 }
155 }
156
157 assertPending := func(t *testing.T, prctx pull.Context, r *Rule, expected string) {
158 allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
159 require.NoError(t, err)
160
161 approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
162 require.NoError(t, err)
163
164 if assert.False(t, approved, "pull request was incorrectly approved") {
165 assert.Equal(t, expected, msg)
166 }
167 }
168
169 t.Run("noApprovalRequired", func(t *testing.T) {
170 prctx := basePullContext()
171 r := &Rule{}
172 assertApproved(t, prctx, r, "No approval required")
173 })
174
175 t.Run("singleApprovalRequired", func(t *testing.T) {
176 prctx := basePullContext()
177 r := &Rule{
178 Requires: Requires{
179 Count: 1,
180 },
181 }
182 assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
183 })
184
185 t.Run("authorCannotApprove", func(t *testing.T) {
186 prctx := basePullContext()
187 r := &Rule{
188 Options: Options{
189 AllowAuthor: false,
190 },
191 Requires: Requires{
192 Count: 1,
193 Actors: common.Actors{
194 Organizations: []string{"everyone"},
195 },
196 },
197 }
198 assertApproved(t, prctx, r, "Approved by comment-approver, review-approver")
199 })
200
201 t.Run("authorCanApprove", func(t *testing.T) {
202 prctx := basePullContext()
203 r := &Rule{
204 Options: Options{
205 AllowAuthor: true,
206 },
207 Requires: Requires{
208 Count: 1,
209 Actors: common.Actors{
210 Organizations: []string{"everyone"},
211 },
212 },
213 }
214 assertApproved(t, prctx, r, "Approved by comment-approver, mhaypenny, review-approver")
215 })
216
217 t.Run("contributorsCannotApprove", func(t *testing.T) {
218 prctx := basePullContext()
219 r := &Rule{
220 Options: Options{
221 AllowContributor: false,
222 },
223 Requires: Requires{
224 Count: 1,
225 Actors: common.Actors{
226 Organizations: []string{"everyone"},
227 },
228 },
229 }
230 assertApproved(t, prctx, r, "Approved by comment-approver, review-approver")
231 })
232
233 t.Run("contributorsIncludingAuthorCanApprove", func(t *testing.T) {
234 prctx := basePullContext()
235 r := &Rule{
236 Options: Options{
237 AllowContributor: true,
238 AllowAuthor: false,
239 },
240 Requires: Requires{
241 Count: 1,
242 Actors: common.Actors{
243 Organizations: []string{"everyone"},
244 },
245 },
246 }
247 assertApproved(t, prctx, r, "Approved by comment-approver, mhaypenny, contributor-author, contributor-committer, review-approver")
248 })
249
250 t.Run("contributorsExcludingAuthorCanApprove", func(t *testing.T) {
251 prctx := basePullContext()
252 r := &Rule{
253 Options: Options{
254 AllowNonAuthorContributor: true,
255 },
256 Requires: Requires{
257 Count: 1,
258 Actors: common.Actors{
259 Organizations: []string{"everyone"},
260 },
261 },
262 }
263 assertApproved(t, prctx, r, "Approved by comment-approver, contributor-author, contributor-committer, review-approver")
264 })
265
266 t.Run("nonAuthorContributorsAndAuthorCanApprove", func(t *testing.T) {
267 prctx := basePullContext()
268 r := &Rule{
269 Options: Options{
270 AllowNonAuthorContributor: true,
271 AllowAuthor: true,
272 },
273 Requires: Requires{
274 Count: 1,
275 Actors: common.Actors{
276 Organizations: []string{"everyone"},
277 },
278 },
279 }
280 assertApproved(t, prctx, r, "Approved by comment-approver, mhaypenny, contributor-author, contributor-committer, review-approver")
281 })
282
283 t.Run("contributorsAndAuthorCanApprove", func(t *testing.T) {
284 prctx := basePullContext()
285 r := &Rule{
286 Options: Options{
287 AllowNonAuthorContributor: true,
288 AllowContributor: true,
289 },
290 Requires: Requires{
291 Count: 1,
292 Actors: common.Actors{
293 Organizations: []string{"everyone"},
294 },
295 },
296 }
297 assertApproved(t, prctx, r, "Approved by comment-approver, mhaypenny, contributor-author, contributor-committer, review-approver")
298 })
299
300 t.Run("specificUserApproves", func(t *testing.T) {
301 prctx := basePullContext()
302 r := &Rule{
303 Requires: Requires{
304 Count: 1,
305 Actors: common.Actors{
306 Users: []string{"comment-approver"},
307 },
308 },
309 }
310 assertApproved(t, prctx, r, "Approved by comment-approver")
311
312 r = &Rule{
313 Requires: Requires{
314 Count: 1,
315 Actors: common.Actors{
316 Users: []string{"does-not-exist"},
317 },
318 },
319 }
320 assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
321 })
322
323 t.Run("specificOrgApproves", func(t *testing.T) {
324 prctx := basePullContext()
325 r := &Rule{
326 Requires: Requires{
327 Count: 1,
328 Actors: common.Actors{
329 Organizations: []string{"cool-org"},
330 },
331 },
332 }
333 assertApproved(t, prctx, r, "Approved by comment-approver")
334
335 r = &Rule{
336 Requires: Requires{
337 Count: 1,
338 Actors: common.Actors{
339 Organizations: []string{"does-not-exist", "other-org"},
340 },
341 },
342 }
343 assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
344 })
345
346 t.Run("specificOrgsOrUserApproves", func(t *testing.T) {
347 prctx := basePullContext()
348 r := &Rule{
349 Requires: Requires{
350 Count: 2,
351 Actors: common.Actors{
352 Users: []string{"review-approver"},
353 Organizations: []string{"cool-org"},
354 },
355 },
356 }
357 assertApproved(t, prctx, r, "Approved by comment-approver, review-approver")
358 })
359
360 t.Run("invalidateCommentOnPush", func(t *testing.T) {
361 prctx := basePullContext()
362 prctx.CommitsValue = []*pull.Commit{
363 {
364 PushedAt: newTime(now.Add(25 * time.Second)),
365 SHA: "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
366 Author: "mhaypenny",
367 Committer: "mhaypenny",
368 },
369 }
370
371 r := &Rule{
372 Requires: Requires{
373 Count: 1,
374 Actors: common.Actors{
375 Users: []string{"comment-approver"},
376 },
377 },
378 }
379 assertApproved(t, prctx, r, "Approved by comment-approver")
380
381 r.Options.InvalidateOnPush = true
382 assertPending(t, prctx, r, "0/1 approvals required. Ignored 6 approvals from disqualified users")
383 })
384
385 t.Run("invalidateReviewOnPush", func(t *testing.T) {
386 prctx := basePullContext()
387 prctx.CommitsValue = []*pull.Commit{
388 {
389 PushedAt: newTime(now.Add(85 * time.Second)),
390 SHA: "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
391 Author: "mhaypenny",
392 Committer: "mhaypenny",
393 },
394 }
395
396 r := &Rule{
397 Requires: Requires{
398 Count: 1,
399 Actors: common.Actors{
400 Users: []string{"review-approver"},
401 },
402 },
403 }
404 assertApproved(t, prctx, r, "Approved by review-approver")
405
406 r.Options.InvalidateOnPush = true
407 assertPending(t, prctx, r, "0/1 approvals required. Ignored 1 approval from disqualified users")
408 })
409
410 t.Run("ignoreUpdateMergeAfterReview", func(t *testing.T) {
411 prctx := basePullContext()
412 prctx.CommitsValue = append(prctx.CommitsValue[:1], &pull.Commit{
413 PushedAt: newTime(now.Add(25 * time.Second)),
414 SHA: "647c5078288f0ea9de27b5c280f25edaf2089045",
415 CommittedViaWeb: true,
416 Parents: []string{
417 "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
418 "2e1b0bb6ab144bf7a1b7a1df9d3bdcb0fe85a206",
419 },
420 Author: "merge-committer",
421 })
422
423 r := &Rule{
424 Requires: Requires{
425 Count: 1,
426 Actors: common.Actors{
427 Users: []string{"comment-approver"},
428 },
429 },
430 Options: Options{
431 InvalidateOnPush: true,
432 },
433 }
434 assertPending(t, prctx, r, "0/1 approvals required. Ignored 6 approvals from disqualified users")
435
436 r.Options.IgnoreUpdateMerges = true
437 assertApproved(t, prctx, r, "Approved by comment-approver")
438 })
439
440 t.Run("ignoreUpdateMergeContributor", func(t *testing.T) {
441 prctx := basePullContext()
442 prctx.CommitsValue = append(prctx.CommitsValue[:1], &pull.Commit{
443 PushedAt: newTime(now.Add(25 * time.Second)),
444 SHA: "647c5078288f0ea9de27b5c280f25edaf2089045",
445 CommittedViaWeb: true,
446 Parents: []string{
447 "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
448 "2e1b0bb6ab144bf7a1b7a1df9d3bdcb0fe85a206",
449 },
450 Author: "merge-committer",
451 })
452 prctx.CommentsValue = append(prctx.CommentsValue, &pull.Comment{
453 CreatedAt: now.Add(100 * time.Second),
454 Author: "merge-committer",
455 Body: ":+1:",
456 })
457
458 r := &Rule{
459 Requires: Requires{
460 Count: 1,
461 Actors: common.Actors{
462 Users: []string{"merge-committer"},
463 },
464 },
465 }
466 assertPending(t, prctx, r, "0/1 approvals required. Ignored 8 approvals from disqualified users")
467
468 r.Options.IgnoreUpdateMerges = true
469 assertApproved(t, prctx, r, "Approved by merge-committer")
470 })
471
472 t.Run("ignoreCommits", func(t *testing.T) {
473 prctx := basePullContext()
474 prctx.CommitsValue = append(prctx.CommitsValue, &pull.Commit{
475 SHA: "ea9be5fcd016dc41d70dc457dfee2e64a8f951c1",
476 Author: "comment-approver",
477 Committer: "comment-approver",
478 })
479
480 r := &Rule{
481 Requires: Requires{
482 Count: 1,
483 Actors: common.Actors{
484 Users: []string{"comment-approver"},
485 },
486 },
487 }
488 assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
489
490 r.Options.IgnoreCommitsBy = common.Actors{
491 Users: []string{"comment-approver"},
492 }
493 assertApproved(t, prctx, r, "Approved by comment-approver")
494 })
495
496 t.Run("ignoreCommitsMixedAuthorCommiter", func(t *testing.T) {
497 prctx := basePullContext()
498
499 r := &Rule{
500 Requires: Requires{
501 Count: 1,
502 Actors: common.Actors{
503 Users: []string{"contributor-author"},
504 },
505 },
506 Options: Options{
507 IgnoreCommitsBy: common.Actors{
508 Users: []string{"contributor-author"},
509 },
510 },
511 }
512 assertPending(t, prctx, r, "0/1 approvals required. Ignored 7 approvals from disqualified users")
513 })
514
515 t.Run("ignoreCommitsInvalidateOnPush", func(t *testing.T) {
516 prctx := basePullContext()
517 prctx.CommitsValue = []*pull.Commit{
518 {
519 PushedAt: newTime(now.Add(25 * time.Second)),
520 SHA: "c6ade256ecfc755d8bc877ef22cc9e01745d46bb",
521 Author: "mhaypenny",
522 Committer: "mhaypenny",
523 },
524 }
525
526 r := &Rule{
527 Requires: Requires{
528 Count: 1,
529 Actors: common.Actors{
530 Users: []string{"comment-approver"},
531 },
532 },
533 }
534 assertApproved(t, prctx, r, "Approved by comment-approver")
535
536 r.Options.InvalidateOnPush = true
537 r.Options.IgnoreCommitsBy = common.Actors{
538 Users: []string{"mhaypenny"},
539 }
540 assertApproved(t, prctx, r, "Approved by comment-approver")
541 })
542
543 t.Run("ignoreEditedReviewComments", func(t *testing.T) {
544 prctx := basePullContext()
545
546 r := &Rule{
547 Requires: Requires{
548 Count: 1,
549 Actors: common.Actors{
550 Users: []string{"review-comment-editor"},
551 },
552 },
553 }
554
555 assertApproved(t, prctx, r, "Approved by review-comment-editor")
556
557 r.Options.IgnoreEditedComments = true
558
559 assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
560 })
561
562 t.Run("ignoreEditedComments", func(t *testing.T) {
563 prctx := basePullContext()
564
565 r := &Rule{
566 Requires: Requires{
567 Count: 1,
568 Actors: common.Actors{
569 Users: []string{"comment-editor"},
570 },
571 },
572 }
573
574 assertApproved(t, prctx, r, "Approved by comment-editor")
575
576 r.Options.IgnoreEditedComments = true
577
578 assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
579 })
580
581 t.Run("ignoreEditedCommentsWithBodyPattern", func(t *testing.T) {
582 prctx := basePullContext()
583
584 r := &Rule{
585 Requires: Requires{
586 Count: 1,
587 Actors: common.Actors{
588 Users: []string{"body-editor"},
589 },
590 },
591 Options: Options{
592 Methods: &common.Methods{
593 BodyPatterns: []common.Regexp{
594 common.NewCompiledRegexp(regexp.MustCompile("/no-platform")),
595 },
596 },
597 IgnoreEditedComments: false,
598 },
599 }
600
601 assertApproved(t, prctx, r, "Approved by body-editor")
602
603 r.Options.IgnoreEditedComments = true
604
605 assertPending(t, prctx, r, "0/1 approvals required. Ignored 5 approvals from disqualified users")
606 })
607 }
608
609 func TestTrigger(t *testing.T) {
610 t.Run("triggerCommitOnRules", func(t *testing.T) {
611 r := &Rule{}
612
613 assert.True(t, r.Trigger().Matches(common.TriggerCommit), "expected %s to match %", r.Trigger(), common.TriggerCommit)
614 })
615
616 t.Run("triggerCommentOnComments", func(t *testing.T) {
617 r := &Rule{
618 Options: Options{
619 Methods: &common.Methods{
620 Comments: []string{
621 "lgtm",
622 },
623 },
624 },
625 Requires: Requires{
626 Count: 1,
627 },
628 }
629
630 assert.True(t, r.Trigger().Matches(common.TriggerCommit), "expected %s to match %s", r.Trigger(), common.TriggerCommit)
631 assert.True(t, r.Trigger().Matches(common.TriggerComment), "expected %s to match %s", r.Trigger(), common.TriggerComment)
632 })
633
634 t.Run("triggerCommentOnCommentPatterns", func(t *testing.T) {
635 r := &Rule{
636 Options: Options{
637 Methods: &common.Methods{
638 CommentPatterns: []common.Regexp{
639 common.NewCompiledRegexp(regexp.MustCompile("(?i)nice")),
640 },
641 },
642 },
643 Requires: Requires{
644 Count: 1,
645 },
646 }
647
648 assert.True(t, r.Trigger().Matches(common.TriggerCommit), "expected %s to match %s", r.Trigger(), common.TriggerCommit)
649 assert.True(t, r.Trigger().Matches(common.TriggerComment), "expected %s to match %s", r.Trigger(), common.TriggerComment)
650 })
651
652 t.Run("triggerReviewForGithubReview", func(t *testing.T) {
653 defaultGithubReview := true
654 r := &Rule{
655 Options: Options{
656 Methods: &common.Methods{
657 GithubReview: &defaultGithubReview,
658 },
659 },
660 Requires: Requires{
661 Count: 1,
662 },
663 }
664
665 assert.True(t, r.Trigger().Matches(common.TriggerCommit), "expected %s to match %s", r.Trigger(), common.TriggerCommit)
666 assert.True(t, r.Trigger().Matches(common.TriggerReview), "expected %s to match %s", r.Trigger(), common.TriggerReview)
667 })
668
669 t.Run("triggerReviewForGithubReviewCommentPatterns", func(t *testing.T) {
670 r := &Rule{
671 Options: Options{
672 Methods: &common.Methods{
673 GithubReviewCommentPatterns: []common.Regexp{
674 common.NewCompiledRegexp(regexp.MustCompile("(?i)nice")),
675 },
676 },
677 },
678 Requires: Requires{
679 Count: 1,
680 },
681 }
682
683 assert.True(t, r.Trigger().Matches(common.TriggerCommit), "expected %s to match %s", r.Trigger(), common.TriggerCommit)
684 assert.True(t, r.Trigger().Matches(common.TriggerReview), "expected %s to match %s", r.Trigger(), common.TriggerReview)
685 })
686
687 t.Run("triggerPullRequestForBodyPatterns", func(t *testing.T) {
688 r := &Rule{
689 Options: Options{
690 Methods: &common.Methods{
691 BodyPatterns: []common.Regexp{
692 common.NewCompiledRegexp(regexp.MustCompile("(?i)nice")),
693 },
694 },
695 IgnoreEditedComments: false,
696 },
697 Requires: Requires{
698 Count: 1,
699 },
700 }
701
702 assert.True(t, r.Trigger().Matches(common.TriggerPullRequest), "expected %s to match %s", r.Trigger(), common.TriggerPullRequest)
703 })
704 }
705
706 func newTime(t time.Time) *time.Time {
707 return &t
708 }
709
View as plain text