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 }