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