1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package approval
16
17 import (
18 "context"
19 "fmt"
20 "sort"
21 "strings"
22 "time"
23
24 "github.com/pkg/errors"
25 "github.com/rs/zerolog"
26
27 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/pull"
28
29 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/common"
30 "edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/predicate"
31 )
32
33 type Rule struct {
34 Name string `yaml:"name"`
35 Description string `yaml:"description,omitempty"`
36 Predicates predicate.Predicates `yaml:"if,omitempty"`
37 Options Options `yaml:"options,omitempty"`
38 Requires Requires `yaml:"requires,omitempty"`
39 }
40
41 type Options struct {
42 AllowAuthor bool `yaml:"allow_author,omitempty"`
43 AllowContributor bool `yaml:"allow_contributor,omitempty"`
44 AllowNonAuthorContributor bool `yaml:"allow_non_author_contributor,omitempty"`
45 InvalidateOnPush bool `yaml:"invalidate_on_push,omitempty"`
46
47 IgnoreEditedComments bool `yaml:"ignore_edited_comments,omitempty"`
48 IgnoreUpdateMerges bool `yaml:"ignore_update_merges,omitempty"`
49 IgnoreCommitsBy common.Actors `yaml:"ignore_commits_by,omitempty"`
50
51 RequestReview RequestReview `yaml:"request_review,omitempty"`
52
53 Methods *common.Methods `yaml:"methods,omitempty"`
54 }
55
56 type RequestReview struct {
57 Enabled bool `yaml:"enabled"`
58 Mode common.RequestMode `yaml:"mode,omitempty"`
59 }
60
61 func (opts *Options) GetMethods() *common.Methods {
62 methods := opts.Methods
63 if methods == nil {
64 methods = &common.Methods{}
65 }
66 if methods.Comments == nil {
67 methods.Comments = []string{
68 ":+1:",
69 "👍",
70 }
71 }
72 if methods.GithubReview == nil {
73 defaultGithubReview := true
74 methods.GithubReview = &defaultGithubReview
75 }
76
77 methods.GithubReviewState = pull.ReviewApproved
78 return methods
79 }
80
81 type Requires struct {
82 Count int `yaml:"count"`
83
84 common.Actors `yaml:",inline"`
85 }
86
87 func (r *Rule) Trigger() common.Trigger {
88 t := common.TriggerCommit
89
90 if r.Requires.Count > 0 {
91 m := r.Options.GetMethods()
92 if len(m.Comments) > 0 || len(m.CommentPatterns) > 0 {
93 t |= common.TriggerComment
94 }
95 if len(m.BodyPatterns) > 0 {
96 t |= common.TriggerPullRequest
97 }
98 if m.GithubReview != nil && *m.GithubReview || len(m.GithubReviewCommentPatterns) > 0 {
99 t |= common.TriggerReview
100 }
101 }
102
103 for _, p := range r.Predicates.Predicates() {
104 t |= p.Trigger()
105 }
106
107 return t
108 }
109
110 func (r *Rule) Evaluate(ctx context.Context, prctx pull.Context) (res common.Result) {
111 log := zerolog.Ctx(ctx)
112
113 res.Name = r.Name
114 res.Description = r.Description
115 res.Status = common.StatusSkipped
116 res.Requires = r.Requires.Actors
117
118 var predicateResults []*common.PredicateResult
119
120 for _, p := range r.Predicates.Predicates() {
121 result, err := p.Evaluate(ctx, prctx)
122 if err != nil {
123 res.Error = errors.Wrap(err, "failed to evaluate predicate")
124 return
125 }
126 predicateResults = append(predicateResults, result)
127
128 if !result.Satisfied {
129 log.Debug().Msgf("skipping rule, predicate of type %T was not satisfied", p)
130
131 desc := result.Description
132 res.StatusDescription = desc
133 if desc == "" {
134 res.StatusDescription = "A precondition of this rule was not satisfied"
135 }
136 res.PredicateResults = []*common.PredicateResult{result}
137 return
138 }
139 }
140 res.PredicateResults = predicateResults
141
142 allowedCandidates, err := r.FilteredCandidates(ctx, prctx)
143 if err != nil {
144 res.Error = errors.Wrap(err, "failed to filter candidates")
145 return
146 }
147
148 approved, msg, err := r.IsApproved(ctx, prctx, allowedCandidates)
149 if err != nil {
150 res.Error = errors.Wrap(err, "failed to compute approval status")
151 return
152 }
153
154 res.AllowedCandidates = allowedCandidates
155
156 res.StatusDescription = msg
157 if approved {
158 res.Status = common.StatusApproved
159 } else {
160 res.Status = common.StatusPending
161 res.ReviewRequestRule = r.getReviewRequestRule()
162 }
163
164 return
165 }
166
167 func (r *Rule) getReviewRequestRule() *common.ReviewRequestRule {
168 if !r.Options.RequestReview.Enabled {
169 return nil
170 }
171
172 mode := r.Options.RequestReview.Mode
173 if mode == "" {
174 mode = common.RequestModeRandomUsers
175 }
176
177 return &common.ReviewRequestRule{
178 Users: r.Requires.Users,
179 Teams: r.Requires.Teams,
180 Organizations: r.Requires.Organizations,
181 Permissions: r.Requires.GetPermissions(),
182 RequiredCount: r.Requires.Count,
183 Mode: mode,
184 }
185 }
186
187 func (r *Rule) IsApproved(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) (bool, string, error) {
188 log := zerolog.Ctx(ctx)
189
190 if r.Requires.Count <= 0 {
191 log.Debug().Msg("rule requires no approvals")
192 return true, "No approval required", nil
193 }
194
195 log.Debug().Msgf("found %d candidates for approval", len(candidates))
196
197
198 banned := make(map[string]bool)
199
200
201
202 author := prctx.Author()
203
204 if !r.Options.AllowAuthor && !r.Options.AllowContributor {
205 banned[author] = true
206 }
207
208
209 if !r.Options.AllowContributor && !r.Options.AllowNonAuthorContributor {
210 commits, err := r.filteredCommits(ctx, prctx)
211 if err != nil {
212 return false, "", err
213 }
214
215 for _, c := range commits {
216 for _, u := range c.Users() {
217 if u != author {
218 banned[u] = true
219 }
220 }
221 }
222 }
223
224
225 var approvers []string
226 for _, c := range candidates {
227 if banned[c.User] {
228 log.Debug().Str("user", c.User).Msg("rejecting approval by banned user")
229 continue
230 }
231
232 isApprover, err := r.Requires.IsActor(ctx, prctx, c.User)
233 if err != nil {
234 return false, "", errors.Wrap(err, "failed to check candidate status")
235 }
236 if !isApprover {
237 log.Debug().Str("user", c.User).Msg("ignoring approval by non-whitelisted user")
238 continue
239 }
240
241 approvers = append(approvers, c.User)
242 }
243
244 log.Debug().Msgf("found %d/%d required approvers", len(approvers), r.Requires.Count)
245 remaining := r.Requires.Count - len(approvers)
246
247 if remaining <= 0 {
248 msg := fmt.Sprintf("Approved by %s", strings.Join(approvers, ", "))
249 return true, msg, nil
250 }
251
252 if len(candidates) > 0 && len(approvers) == 0 {
253 msg := fmt.Sprintf("%d/%d approvals required. Ignored %s from disqualified users",
254 len(approvers),
255 r.Requires.Count,
256 numberOfApprovals(len(candidates)))
257 return false, msg, nil
258 }
259
260 msg := fmt.Sprintf("%d/%d approvals required", len(approvers), r.Requires.Count)
261 return false, msg, nil
262 }
263
264 func (r *Rule) FilteredCandidates(ctx context.Context, prctx pull.Context) ([]*common.Candidate, error) {
265 candidates, err := r.Options.GetMethods().Candidates(ctx, prctx)
266 if err != nil {
267 return nil, errors.Wrap(err, "failed to get approval candidates")
268 }
269
270 sort.Stable(common.CandidatesByCreationTime(candidates))
271
272 if r.Options.IgnoreEditedComments {
273 candidates, err = r.filterEditedCandidates(ctx, prctx, candidates)
274 if err != nil {
275 return nil, err
276 }
277 }
278
279 if r.Options.InvalidateOnPush {
280 candidates, err = r.filterInvalidCandidates(ctx, prctx, candidates)
281 if err != nil {
282 return nil, err
283 }
284 }
285
286 return candidates, nil
287 }
288
289 func (r *Rule) filterEditedCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, error) {
290 log := zerolog.Ctx(ctx)
291
292 if !r.Options.IgnoreEditedComments {
293 return candidates, nil
294 }
295
296 var allowedCandidates []*common.Candidate
297 for _, candidate := range candidates {
298 if candidate.LastEditedAt.IsZero() {
299 allowedCandidates = append(allowedCandidates, candidate)
300 }
301 }
302
303 log.Debug().Msgf("discarded %d candidates with edited comments", len(candidates)-len(allowedCandidates))
304
305 return allowedCandidates, nil
306 }
307
308 func (r *Rule) filterInvalidCandidates(ctx context.Context, prctx pull.Context, candidates []*common.Candidate) ([]*common.Candidate, error) {
309 log := zerolog.Ctx(ctx)
310
311 if !r.Options.InvalidateOnPush {
312 return candidates, nil
313 }
314
315 commits, err := r.filteredCommits(ctx, prctx)
316 if err != nil {
317 return nil, err
318 }
319 if len(commits) == 0 {
320 return candidates, nil
321 }
322
323 last := findLastPushed(commits)
324 if last == nil {
325 return nil, errors.New("no commit contained a push date")
326 }
327
328 var allowedCandidates []*common.Candidate
329 for _, candidate := range candidates {
330 if candidate.CreatedAt.After(*last.PushedAt) {
331 allowedCandidates = append(allowedCandidates, candidate)
332 }
333 }
334
335 log.Debug().Msgf("discarded %d candidates invalidated by push of %s at %s",
336 len(candidates)-len(allowedCandidates),
337 last.SHA,
338 last.PushedAt.Format(time.RFC3339))
339
340 return allowedCandidates, nil
341 }
342
343 func (r *Rule) filteredCommits(ctx context.Context, prctx pull.Context) ([]*pull.Commit, error) {
344 commits, err := prctx.Commits()
345 if err != nil {
346 return nil, errors.Wrap(err, "failed to list commits")
347 }
348
349 ignoreUpdates := r.Options.IgnoreUpdateMerges
350 ignoreCommits := !r.Options.IgnoreCommitsBy.IsEmpty()
351
352 if !ignoreUpdates && !ignoreCommits {
353 return commits, nil
354 }
355
356 var filtered []*pull.Commit
357 for _, c := range commits {
358 if ignoreUpdates {
359 if isUpdateMerge(commits, c) {
360 continue
361 }
362 }
363
364 if ignoreCommits {
365 ignore, err := isIgnoredCommit(ctx, prctx, &r.Options.IgnoreCommitsBy, c)
366 if err != nil {
367 return nil, err
368 }
369 if ignore {
370 continue
371 }
372 }
373
374 filtered = append(filtered, c)
375 }
376 return filtered, nil
377 }
378
379 func isUpdateMerge(commits []*pull.Commit, c *pull.Commit) bool {
380
381 if len(c.Parents) != 2 {
382 return false
383 }
384
385
386 if !c.CommittedViaWeb {
387 return false
388 }
389
390 shas := make(map[string]bool)
391 for _, c := range commits {
392 shas[c.SHA] = true
393 }
394
395
396
397 return shas[c.Parents[0]] && !shas[c.Parents[1]]
398 }
399
400 func isIgnoredCommit(ctx context.Context, prctx pull.Context, actors *common.Actors, c *pull.Commit) (bool, error) {
401 for _, u := range c.Users() {
402 ignored, err := actors.IsActor(ctx, prctx, u)
403 if err != nil {
404 return false, err
405 }
406 if !ignored {
407 return false, nil
408 }
409 }
410
411 return len(c.Users()) > 0, nil
412 }
413
414 func findLastPushed(commits []*pull.Commit) *pull.Commit {
415 var last *pull.Commit
416 for _, c := range commits {
417 if c.PushedAt != nil && (last == nil || c.PushedAt.After(*last.PushedAt)) {
418 last = c
419 }
420 }
421 return last
422 }
423
424 func numberOfApprovals(count int) string {
425 if count == 1 {
426 return "1 approval"
427 }
428 return fmt.Sprintf("%d approvals", count)
429 }
430
View as plain text