github.com/abayer/test-infra@v0.0.5/prow/plugins/blunderbuss/blunderbuss.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package blunderbuss
    18  
    19  import (
    20  	"fmt"
    21  	"math"
    22  	"math/rand"
    23  	"sort"
    24  
    25  	"github.com/sirupsen/logrus"
    26  
    27  	"k8s.io/apimachinery/pkg/util/sets"
    28  	"k8s.io/test-infra/prow/github"
    29  	"k8s.io/test-infra/prow/pluginhelp"
    30  	"k8s.io/test-infra/prow/plugins"
    31  	"k8s.io/test-infra/prow/plugins/assign"
    32  )
    33  
    34  const (
    35  	pluginName = "blunderbuss"
    36  )
    37  
    38  func init() {
    39  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    40  }
    41  
    42  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    43  	var pluralSuffix string
    44  	var reviewCount int
    45  	if config.Blunderbuss.ReviewerCount != nil {
    46  		reviewCount = *config.Blunderbuss.ReviewerCount
    47  	} else if config.Blunderbuss.ReviewerCount != nil {
    48  		reviewCount = *config.Blunderbuss.FileWeightCount
    49  	}
    50  	if reviewCount != 1 {
    51  		pluralSuffix = "s"
    52  	}
    53  	// Omit the fields [WhoCanUse, Usage, Examples] because this plugin is not triggered by human actions.
    54  	return &pluginhelp.PluginHelp{
    55  			Description: "The blunderbuss plugin automatically requests reviews from reviewers when a new PR is created. The reviewers are selected based on the reviewers specified in the OWNERS files that apply to the files modified by the PR.",
    56  			Config: map[string]string{
    57  				"": fmt.Sprintf("Blunderbuss is currently configured to request reviews from %d reviewer%s.", reviewCount, pluralSuffix),
    58  			},
    59  		},
    60  		nil
    61  }
    62  
    63  type reviewersClient interface {
    64  	FindReviewersOwnersForFile(path string) string
    65  	Reviewers(path string) sets.String
    66  	RequiredReviewers(path string) sets.String
    67  	LeafReviewers(path string) sets.String
    68  }
    69  
    70  type ownersClient interface {
    71  	reviewersClient
    72  	FindApproverOwnersForFile(path string) string
    73  	Approvers(path string) sets.String
    74  	LeafApprovers(path string) sets.String
    75  }
    76  
    77  type fallbackReviewersClient struct {
    78  	ownersClient
    79  }
    80  
    81  func (foc fallbackReviewersClient) FindReviewersOwnersForFile(path string) string {
    82  	return foc.ownersClient.FindApproverOwnersForFile(path)
    83  }
    84  
    85  func (foc fallbackReviewersClient) Reviewers(path string) sets.String {
    86  	return foc.ownersClient.Approvers(path)
    87  }
    88  
    89  func (foc fallbackReviewersClient) LeafReviewers(path string) sets.String {
    90  	return foc.ownersClient.LeafApprovers(path)
    91  }
    92  
    93  type githubClient interface {
    94  	RequestReview(org, repo string, number int, logins []string) error
    95  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    96  }
    97  
    98  func handlePullRequest(pc plugins.PluginClient, pre github.PullRequestEvent) error {
    99  	if pre.Action != github.PullRequestActionOpened || assign.CCRegexp.MatchString(pre.PullRequest.Body) {
   100  		return nil
   101  	}
   102  
   103  	oc, err := pc.OwnersClient.LoadRepoOwners(pre.Repo.Owner.Login, pre.Repo.Name, pre.PullRequest.Base.Ref)
   104  	if err != nil {
   105  		return fmt.Errorf("error loading RepoOwners: %v", err)
   106  	}
   107  
   108  	return handle(
   109  		pc.GitHubClient,
   110  		oc, pc.Logger,
   111  		pc.PluginConfig.Blunderbuss.ReviewerCount,
   112  		pc.PluginConfig.Blunderbuss.FileWeightCount,
   113  		pc.PluginConfig.Blunderbuss.MaxReviewerCount,
   114  		pc.PluginConfig.Blunderbuss.ExcludeApprovers,
   115  		&pre,
   116  	)
   117  }
   118  
   119  func handle(ghc githubClient, oc ownersClient, log *logrus.Entry, reviewerCount, oldReviewCount *int, maxReviewers int, excludeApprovers bool, pre *github.PullRequestEvent) error {
   120  	changes, err := ghc.GetPullRequestChanges(pre.Repo.Owner.Login, pre.Repo.Name, pre.Number)
   121  	if err != nil {
   122  		return fmt.Errorf("error getting PR changes: %v", err)
   123  	}
   124  
   125  	var reviewers []string
   126  	var requiredReviewers []string
   127  	switch {
   128  	case oldReviewCount != nil:
   129  		reviewers = getReviewersOld(log, oc, pre.PullRequest.User.Login, changes, *oldReviewCount)
   130  	case reviewerCount != nil:
   131  		reviewers, requiredReviewers, err = getReviewers(oc, pre.PullRequest.User.Login, changes, *reviewerCount)
   132  		if err != nil {
   133  			return err
   134  		}
   135  		if missing := *reviewerCount - len(reviewers); missing > 0 {
   136  			if !excludeApprovers {
   137  				// Attempt to use approvers as additional reviewers. This must use
   138  				// reviewerCount instead of missing because owners can be both reviewers
   139  				// and approvers and the search might stop too early if it finds
   140  				// duplicates.
   141  				frc := fallbackReviewersClient{ownersClient: oc}
   142  				approvers, _, err := getReviewers(frc, pre.PullRequest.User.Login, changes, *reviewerCount)
   143  				if err != nil {
   144  					return err
   145  				}
   146  				combinedReviewers := sets.NewString(reviewers...)
   147  				combinedReviewers.Insert(approvers...)
   148  				log.Infof("Added %d approvers as reviewers. %d/%d reviewers found.", combinedReviewers.Len()-len(reviewers), combinedReviewers.Len(), *reviewerCount)
   149  				reviewers = combinedReviewers.List()
   150  			}
   151  		}
   152  		if missing := *reviewerCount - len(reviewers); missing > 0 {
   153  			log.Warnf("Not enough reviewers found in OWNERS files for files touched by this PR. %d/%d reviewers found.", len(reviewers), *reviewerCount)
   154  		}
   155  	}
   156  
   157  	if maxReviewers > 0 && len(reviewers) > maxReviewers {
   158  		log.Infof("Limiting request of %d reviewers to %d maxReviewers.", len(reviewers), maxReviewers)
   159  		reviewers = reviewers[:maxReviewers]
   160  	}
   161  
   162  	// add required reviewers if any
   163  	reviewers = append(reviewers, requiredReviewers...)
   164  
   165  	if len(reviewers) > 0 {
   166  		log.Infof("Requesting reviews from users %s.", reviewers)
   167  		return ghc.RequestReview(pre.Repo.Owner.Login, pre.Repo.Name, pre.Number, reviewers)
   168  	}
   169  	return nil
   170  }
   171  
   172  func getReviewers(rc reviewersClient, author string, files []github.PullRequestChange, minReviewers int) ([]string, []string, error) {
   173  	authorSet := sets.NewString(author)
   174  	reviewers := sets.NewString()
   175  	requiredReviewers := sets.NewString()
   176  	leafReviewers := sets.NewString()
   177  	ownersSeen := sets.NewString()
   178  	// first build 'reviewers' by taking a unique reviewer from each OWNERS file.
   179  	for _, file := range files {
   180  		ownersFile := rc.FindReviewersOwnersForFile(file.Filename)
   181  		if ownersSeen.Has(ownersFile) {
   182  			continue
   183  		}
   184  		ownersSeen.Insert(ownersFile)
   185  
   186  		// record required reviewers if any
   187  		requiredReviewers.Insert(rc.RequiredReviewers(file.Filename).UnsortedList()...)
   188  
   189  		fileUnusedLeafs := rc.LeafReviewers(file.Filename).Difference(reviewers).Difference(authorSet)
   190  		if fileUnusedLeafs.Len() == 0 {
   191  			continue
   192  		}
   193  		leafReviewers = leafReviewers.Union(fileUnusedLeafs)
   194  		reviewers.Insert(popRandom(fileUnusedLeafs))
   195  	}
   196  	// now ensure that we request review from at least minReviewers reviewers. Favor leaf reviewers.
   197  	unusedLeafs := leafReviewers.Difference(reviewers)
   198  	for reviewers.Len() < minReviewers && unusedLeafs.Len() > 0 {
   199  		reviewers.Insert(popRandom(unusedLeafs))
   200  	}
   201  	for _, file := range files {
   202  		if reviewers.Len() >= minReviewers {
   203  			break
   204  		}
   205  		fileReviewers := rc.Reviewers(file.Filename).Difference(authorSet)
   206  		for reviewers.Len() < minReviewers && fileReviewers.Len() > 0 {
   207  			reviewers.Insert(popRandom(fileReviewers))
   208  		}
   209  	}
   210  	return reviewers.List(), requiredReviewers.List(), nil
   211  }
   212  
   213  // popRandom randomly selects an element of 'set' and pops it.
   214  func popRandom(set sets.String) string {
   215  	list := set.List()
   216  	sort.Strings(list)
   217  	sel := list[rand.Intn(len(list))]
   218  	set.Delete(sel)
   219  	return sel
   220  }
   221  
   222  func getReviewersOld(log *logrus.Entry, oc ownersClient, author string, changes []github.PullRequestChange, reviewerCount int) []string {
   223  	potentialReviewers, weightSum := getPotentialReviewers(oc, author, changes, true)
   224  	reviewers := selectMultipleReviewers(log, potentialReviewers, weightSum, reviewerCount)
   225  	if len(reviewers) < reviewerCount {
   226  		// Didn't find enough leaf reviewers, need to include reviewers from parent OWNERS files.
   227  		potentialReviewers, weightSum := getPotentialReviewers(oc, author, changes, false)
   228  		for _, reviewer := range reviewers {
   229  			delete(potentialReviewers, reviewer)
   230  		}
   231  		reviewers = append(reviewers, selectMultipleReviewers(log, potentialReviewers, weightSum, reviewerCount-len(reviewers))...)
   232  		if missing := reviewerCount - len(reviewers); missing > 0 {
   233  			log.Errorf("Not enough reviewers found in OWNERS files for files touched by this PR. %d/%d reviewers found.", len(reviewers), reviewerCount)
   234  		}
   235  	}
   236  	return reviewers
   237  }
   238  
   239  // weightMap is a map of user to a weight for that user.
   240  type weightMap map[string]int64
   241  
   242  func getPotentialReviewers(owners ownersClient, author string, files []github.PullRequestChange, leafOnly bool) (weightMap, int64) {
   243  	potentialReviewers := weightMap{}
   244  	weightSum := int64(0)
   245  	var fileOwners sets.String
   246  	for _, file := range files {
   247  		fileWeight := int64(1)
   248  		if file.Changes != 0 {
   249  			fileWeight = int64(file.Changes)
   250  		}
   251  		// Judge file size on a log scale-- effectively this
   252  		// makes three buckets, we shouldn't have many 10k+
   253  		// line changes.
   254  		fileWeight = int64(math.Log10(float64(fileWeight))) + 1
   255  		if leafOnly {
   256  			fileOwners = owners.LeafReviewers(file.Filename)
   257  		} else {
   258  			fileOwners = owners.Reviewers(file.Filename)
   259  		}
   260  
   261  		for _, owner := range fileOwners.List() {
   262  			if owner == author {
   263  				continue
   264  			}
   265  			potentialReviewers[owner] = potentialReviewers[owner] + fileWeight
   266  			weightSum += fileWeight
   267  		}
   268  	}
   269  	return potentialReviewers, weightSum
   270  }
   271  
   272  func selectMultipleReviewers(log *logrus.Entry, potentialReviewers weightMap, weightSum int64, count int) []string {
   273  	for name, weight := range potentialReviewers {
   274  		log.Debugf("Reviewer %s had chance %02.2f%%", name, chance(weight, weightSum))
   275  	}
   276  
   277  	// Make a copy of the map
   278  	pOwners := weightMap{}
   279  	for k, v := range potentialReviewers {
   280  		pOwners[k] = v
   281  	}
   282  
   283  	owners := []string{}
   284  
   285  	for i := 0; i < count; i++ {
   286  		if len(pOwners) == 0 || weightSum == 0 {
   287  			break
   288  		}
   289  		selection := rand.Int63n(weightSum)
   290  		owner := ""
   291  		for o, w := range pOwners {
   292  			owner = o
   293  			selection -= w
   294  			if selection <= 0 {
   295  				break
   296  			}
   297  		}
   298  
   299  		owners = append(owners, owner)
   300  		weightSum -= pOwners[owner]
   301  
   302  		// Remove this person from the map.
   303  		delete(pOwners, owner)
   304  	}
   305  	return owners
   306  }
   307  
   308  func chance(val, total int64) float64 {
   309  	return 100.0 * float64(val) / float64(total)
   310  }