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 }