...

Source file src/edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/approval/approve.go

Documentation: edge-infra.dev/pkg/f8n/devinfra/repo/owners/policybot/policy/approval

     1  // Copyright 2018 Palantir Technologies, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    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  	// collect users "banned" by approval options
   198  	banned := make(map[string]bool)
   199  
   200  	// "author" is the user who opened the PR
   201  	// if contributors are allowed, the author counts as a contributor
   202  	author := prctx.Author()
   203  
   204  	if !r.Options.AllowAuthor && !r.Options.AllowContributor {
   205  		banned[author] = true
   206  	}
   207  
   208  	// "contributor" is any user who added a commit to the PR
   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  	// filter real approvers using banned status and required membership
   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  	// must be a simple merge commit (exactly 2 parents)
   381  	if len(c.Parents) != 2 {
   382  		return false
   383  	}
   384  
   385  	// must be created via the UI or the API (no local merges)
   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  	// first parent must exist: it is a commit on the head branch
   396  	// second parent must not exist: it is already in the base branch
   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  	// either all users are ignored or the commit has no users; only ignore in the first case
   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