github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/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 "context" 21 "fmt" 22 "regexp" 23 24 githubql "github.com/shurcooL/githubv4" 25 "github.com/sirupsen/logrus" 26 "k8s.io/apimachinery/pkg/util/sets" 27 "sigs.k8s.io/prow/pkg/layeredsets" 28 29 "sigs.k8s.io/prow/pkg/config" 30 "sigs.k8s.io/prow/pkg/github" 31 "sigs.k8s.io/prow/pkg/pluginhelp" 32 "sigs.k8s.io/prow/pkg/plugins" 33 "sigs.k8s.io/prow/pkg/plugins/assign" 34 "sigs.k8s.io/prow/pkg/repoowners" 35 ) 36 37 const ( 38 // PluginName defines this plugin's registered name. 39 PluginName = "blunderbuss" 40 ) 41 42 var ( 43 match = regexp.MustCompile(`(?mi)^/auto-cc\s*$`) 44 ) 45 46 func init() { 47 plugins.RegisterPullRequestHandler(PluginName, handlePullRequestEvent, helpProvider) 48 plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider) 49 } 50 51 func configString(reviewCount int) string { 52 var pluralSuffix string 53 if reviewCount > 1 { 54 pluralSuffix = "s" 55 } 56 return fmt.Sprintf("Blunderbuss is currently configured to request reviews from %d reviewer%s.", reviewCount, pluralSuffix) 57 } 58 59 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 60 var reviewCount int 61 if config.Blunderbuss.ReviewerCount != nil { 62 reviewCount = *config.Blunderbuss.ReviewerCount 63 } 64 two := 2 65 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 66 Blunderbuss: plugins.Blunderbuss{ 67 ReviewerCount: &two, 68 MaxReviewerCount: 3, 69 ExcludeApprovers: true, 70 UseStatusAvailability: true, 71 IgnoreAuthors: []string{}, 72 }, 73 }) 74 if err != nil { 75 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName) 76 } 77 pluginHelp := &pluginhelp.PluginHelp{ 78 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.", 79 Config: map[string]string{ 80 "": configString(reviewCount), 81 }, 82 Snippet: yamlSnippet, 83 } 84 pluginHelp.AddCommand(pluginhelp.Command{ 85 Usage: "/auto-cc", 86 Featured: false, 87 Description: "Manually request reviews from reviewers for a PR. Useful if OWNERS file were updated since the PR was opened.", 88 Examples: []string{"/auto-cc"}, 89 WhoCanUse: "Anyone", 90 }) 91 return pluginHelp, nil 92 } 93 94 type reviewersClient interface { 95 FindReviewersOwnersForFile(path string) string 96 Reviewers(path string) layeredsets.String 97 RequiredReviewers(path string) sets.Set[string] 98 LeafReviewers(path string) sets.Set[string] 99 } 100 101 type ownersClient interface { 102 reviewersClient 103 FindApproverOwnersForFile(path string) string 104 Approvers(path string) layeredsets.String 105 LeafApprovers(path string) sets.Set[string] 106 } 107 108 type fallbackReviewersClient struct { 109 ownersClient 110 } 111 112 func (foc fallbackReviewersClient) FindReviewersOwnersForFile(path string) string { 113 return foc.ownersClient.FindApproverOwnersForFile(path) 114 } 115 116 func (foc fallbackReviewersClient) Reviewers(path string) layeredsets.String { 117 return foc.ownersClient.Approvers(path) 118 } 119 120 func (foc fallbackReviewersClient) LeafReviewers(path string) sets.Set[string] { 121 return foc.ownersClient.LeafApprovers(path) 122 } 123 124 type githubClient interface { 125 RequestReview(org, repo string, number int, logins []string) error 126 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 127 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 128 Query(context.Context, interface{}, map[string]interface{}) error 129 } 130 131 type repoownersClient interface { 132 LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error) 133 } 134 135 func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error { 136 return handlePullRequest( 137 pc.GitHubClient, 138 pc.OwnersClient, 139 pc.Logger, 140 pc.PluginConfig.Blunderbuss, 141 pre.Action, 142 &pre.PullRequest, 143 &pre.Repo, 144 ) 145 } 146 147 func handlePullRequest(ghc githubClient, roc repoownersClient, log *logrus.Entry, config plugins.Blunderbuss, action github.PullRequestEventAction, pr *github.PullRequest, repo *github.Repo) error { 148 if !(action == github.PullRequestActionOpened || action == github.PullRequestActionReadyForReview) || assign.CCRegexp.MatchString(pr.Body) { 149 return nil 150 } 151 if pr.Draft && config.IgnoreDrafts { 152 // ignore Draft PR when IgnoreDrafts is true 153 return nil 154 } 155 // Ignore PRs submitted by users matching logins set in IgnoreAuthors 156 for _, user := range config.IgnoreAuthors { 157 if user == pr.User.Login { 158 return nil 159 } 160 } 161 return handle( 162 ghc, 163 roc, 164 log, 165 config.ReviewerCount, 166 config.MaxReviewerCount, 167 config.ExcludeApprovers, 168 config.UseStatusAvailability, 169 repo, 170 pr, 171 ) 172 } 173 174 func handleGenericCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { 175 return handleGenericComment( 176 pc.GitHubClient, 177 pc.OwnersClient, 178 pc.Logger, 179 pc.PluginConfig.Blunderbuss, 180 ce.Action, 181 ce.IsPR, 182 ce.Number, 183 ce.IssueState, 184 &ce.Repo, 185 ce.Body, 186 ) 187 } 188 189 func handleGenericComment(ghc githubClient, roc repoownersClient, log *logrus.Entry, config plugins.Blunderbuss, action github.GenericCommentEventAction, isPR bool, prNumber int, issueState string, repo *github.Repo, body string) error { 190 if action != github.GenericCommentActionCreated || !isPR || issueState == "closed" { 191 return nil 192 } 193 194 if !match.MatchString(body) { 195 return nil 196 } 197 198 pr, err := ghc.GetPullRequest(repo.Owner.Login, repo.Name, prNumber) 199 if err != nil { 200 return fmt.Errorf("error loading PullRequest: %w", err) 201 } 202 203 return handle( 204 ghc, 205 roc, 206 log, 207 config.ReviewerCount, 208 config.MaxReviewerCount, 209 config.ExcludeApprovers, 210 config.UseStatusAvailability, 211 repo, 212 pr, 213 ) 214 } 215 216 func handle(ghc githubClient, roc repoownersClient, log *logrus.Entry, reviewerCount *int, maxReviewers int, excludeApprovers bool, useStatusAvailability bool, repo *github.Repo, pr *github.PullRequest) error { 217 oc, err := roc.LoadRepoOwners(repo.Owner.Login, repo.Name, pr.Base.Ref) 218 if err != nil { 219 return fmt.Errorf("error loading RepoOwners: %w", err) 220 } 221 222 changes, err := ghc.GetPullRequestChanges(repo.Owner.Login, repo.Name, pr.Number) 223 if err != nil { 224 return fmt.Errorf("error getting PR changes: %w", err) 225 } 226 227 var reviewers []string 228 var requiredReviewers []string 229 if reviewerCount != nil { 230 reviewers, requiredReviewers, err = getReviewers(oc, ghc, log, pr.User.Login, changes, *reviewerCount, useStatusAvailability) 231 if err != nil { 232 return err 233 } 234 if missing := *reviewerCount - len(reviewers); missing > 0 { 235 if !excludeApprovers { 236 // Attempt to use approvers as additional reviewers. This must use 237 // reviewerCount instead of missing because owners can be both reviewers 238 // and approvers and the search might stop too early if it finds 239 // duplicates. 240 frc := fallbackReviewersClient{ownersClient: oc} 241 approvers, _, err := getReviewers(frc, ghc, log, pr.User.Login, changes, *reviewerCount, useStatusAvailability) 242 if err != nil { 243 return err 244 } 245 var added int 246 combinedReviewers := sets.New[string](reviewers...) 247 for _, approver := range approvers { 248 if !combinedReviewers.Has(approver) { 249 reviewers = append(reviewers, approver) 250 combinedReviewers.Insert(approver) 251 added++ 252 } 253 } 254 log.Infof("Added %d approvers as reviewers. %d/%d reviewers found.", added, combinedReviewers.Len(), *reviewerCount) 255 } 256 } 257 if missing := *reviewerCount - len(reviewers); missing > 0 { 258 log.Debugf("Not enough reviewers found in OWNERS files for files touched by this PR. %d/%d reviewers found.", len(reviewers), *reviewerCount) 259 } 260 } 261 262 if maxReviewers > 0 && len(reviewers) > maxReviewers { 263 log.Infof("Limiting request of %d reviewers to %d maxReviewers.", len(reviewers), maxReviewers) 264 reviewers = reviewers[:maxReviewers] 265 } 266 267 // add required reviewers if any 268 reviewers = append(reviewers, requiredReviewers...) 269 270 if len(reviewers) > 0 { 271 log.Infof("Requesting reviews from users %s.", reviewers) 272 return ghc.RequestReview(repo.Owner.Login, repo.Name, pr.Number, reviewers) 273 } 274 return nil 275 } 276 277 func getReviewers(rc reviewersClient, ghc githubClient, log *logrus.Entry, author string, files []github.PullRequestChange, minReviewers int, useStatusAvailability bool) ([]string, []string, error) { 278 authorSet := sets.New[string](github.NormLogin(author)) 279 reviewers := layeredsets.NewString() 280 requiredReviewers := sets.New[string]() 281 leafReviewers := layeredsets.NewString() 282 busyReviewers := sets.New[string]() 283 ownersSeen := sets.New[string]() 284 if minReviewers == 0 { 285 return reviewers.List(), sets.List(requiredReviewers), nil 286 } 287 // first build 'reviewers' by taking a unique reviewer from each OWNERS file. 288 for _, file := range files { 289 ownersFile := rc.FindReviewersOwnersForFile(file.Filename) 290 if ownersSeen.Has(ownersFile) { 291 continue 292 } 293 ownersSeen.Insert(ownersFile) 294 295 // record required reviewers if any 296 requiredReviewers.Insert(rc.RequiredReviewers(file.Filename).UnsortedList()...) 297 298 fileUnusedLeafs := layeredsets.NewString(sets.List(rc.LeafReviewers(file.Filename))...).Difference(reviewers.Set()).Difference(authorSet) 299 if fileUnusedLeafs.Len() == 0 { 300 continue 301 } 302 leafReviewers = leafReviewers.Union(fileUnusedLeafs) 303 if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &fileUnusedLeafs); r != "" { 304 reviewers.Insert(0, r) 305 } 306 } 307 // now ensure that we request review from at least minReviewers reviewers. Favor leaf reviewers. 308 unusedLeafs := leafReviewers.Difference(reviewers.Set()) 309 for reviewers.Len() < minReviewers && unusedLeafs.Len() > 0 { 310 if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &unusedLeafs); r != "" { 311 reviewers.Insert(1, r) 312 } 313 } 314 for _, file := range files { 315 if reviewers.Len() >= minReviewers { 316 break 317 } 318 fileReviewers := rc.Reviewers(file.Filename).Difference(authorSet) 319 for reviewers.Len() < minReviewers && fileReviewers.Len() > 0 { 320 if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &fileReviewers); r != "" { 321 reviewers.Insert(2, r) 322 } 323 } 324 } 325 return reviewers.List(), sets.List(requiredReviewers), nil 326 } 327 328 // findReviewer finds a reviewer from a set, potentially using status 329 // availability. 330 func findReviewer(ghc githubClient, log *logrus.Entry, useStatusAvailability bool, busyReviewers *sets.Set[string], targetSet *layeredsets.String) string { 331 // if we don't care about status availability, just pop a target from the set 332 if !useStatusAvailability { 333 return targetSet.PopRandom() 334 } 335 336 // if we do care, start looping through the candidates 337 for { 338 if targetSet.Len() == 0 { 339 // if there are no candidates left, then break 340 break 341 } 342 candidate := targetSet.PopRandom() 343 if busyReviewers.Has(candidate) { 344 // we've already verified this reviewer is busy 345 continue 346 } 347 busy, err := isUserBusy(ghc, candidate) 348 if err != nil { 349 log.WithField("user", candidate).WithError(err).Error("Error checking user availability") 350 } 351 if !busy { 352 return candidate 353 } 354 // if we haven't returned the candidate, then they must be busy. 355 log.WithField("user", candidate).Debug("User marked as a busy reviewer") 356 busyReviewers.Insert(candidate) 357 } 358 return "" 359 } 360 361 type githubAvailabilityQuery struct { 362 User struct { 363 Login githubql.String 364 Status struct { 365 IndicatesLimitedAvailability githubql.Boolean 366 } 367 } `graphql:"user(login: $user)"` 368 } 369 370 func isUserBusy(ghc githubClient, user string) (bool, error) { 371 var query githubAvailabilityQuery 372 vars := map[string]interface{}{ 373 "user": githubql.String(user), 374 } 375 ctx := context.Background() 376 err := ghc.Query(ctx, &query, vars) 377 return bool(query.User.Status.IndicatesLimitedAvailability), err 378 }