github.com/abayer/test-infra@v0.0.5/prow/plugins/approve/approvers/owners.go (about) 1 /* 2 Copyright 2016 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 approvers 18 19 import ( 20 "bytes" 21 "encoding/json" 22 "fmt" 23 "math/rand" 24 "path/filepath" 25 "sort" 26 "strings" 27 "text/template" 28 29 "github.com/sirupsen/logrus" 30 31 "k8s.io/apimachinery/pkg/util/sets" 32 ) 33 34 const ( 35 ownersFileName = "OWNERS" 36 ApprovalNotificationName = "ApprovalNotifier" 37 ) 38 39 type RepoInterface interface { 40 Approvers(path string) sets.String 41 LeafApprovers(path string) sets.String 42 FindApproverOwnersForFile(file string) string 43 IsNoParentOwners(path string) bool 44 } 45 46 type Owners struct { 47 filenames []string 48 repo RepoInterface 49 seed int64 50 51 log *logrus.Entry 52 } 53 54 func NewOwners(log *logrus.Entry, filenames []string, r RepoInterface, s int64) Owners { 55 return Owners{filenames: filenames, repo: r, seed: s, log: log} 56 } 57 58 // GetApprovers returns a map from ownersFiles -> people that are approvers in them 59 func (o Owners) GetApprovers() map[string]sets.String { 60 ownersToApprovers := map[string]sets.String{} 61 62 for fn := range o.GetOwnersSet() { 63 ownersToApprovers[fn] = o.repo.Approvers(fn) 64 } 65 66 return ownersToApprovers 67 } 68 69 // GetLeafApprovers returns a map from ownersFiles -> people that are approvers in them (only the leaf) 70 func (o Owners) GetLeafApprovers() map[string]sets.String { 71 ownersToApprovers := map[string]sets.String{} 72 73 for fn := range o.GetOwnersSet() { 74 ownersToApprovers[fn] = o.repo.LeafApprovers(fn) 75 } 76 77 return ownersToApprovers 78 } 79 80 // GetAllPotentialApprovers returns the people from relevant owners files needed to get the PR approved 81 func (o Owners) GetAllPotentialApprovers() []string { 82 approversOnly := []string{} 83 for _, approverList := range o.GetLeafApprovers() { 84 for approver := range approverList { 85 approversOnly = append(approversOnly, approver) 86 } 87 } 88 sort.Strings(approversOnly) 89 return approversOnly 90 } 91 92 // GetReverseMap returns a map from people -> OWNERS files for which they are an approver 93 func (o Owners) GetReverseMap(approvers map[string]sets.String) map[string]sets.String { 94 approverOwnersfiles := map[string]sets.String{} 95 for ownersFile, approvers := range approvers { 96 for approver := range approvers { 97 if _, ok := approverOwnersfiles[approver]; ok { 98 approverOwnersfiles[approver].Insert(ownersFile) 99 } else { 100 approverOwnersfiles[approver] = sets.NewString(ownersFile) 101 } 102 } 103 } 104 return approverOwnersfiles 105 } 106 107 func findMostCoveringApprover(allApprovers []string, reverseMap map[string]sets.String, unapproved sets.String) string { 108 maxCovered := 0 109 var bestPerson string 110 for _, approver := range allApprovers { 111 filesCanApprove := reverseMap[approver] 112 if filesCanApprove.Intersection(unapproved).Len() > maxCovered { 113 maxCovered = len(filesCanApprove) 114 bestPerson = approver 115 } 116 } 117 return bestPerson 118 } 119 120 // temporaryUnapprovedFiles returns the list of files that wouldn't be 121 // approved by the given set of approvers. 122 func (o Owners) temporaryUnapprovedFiles(approvers sets.String) sets.String { 123 ap := NewApprovers(o) 124 for approver := range approvers { 125 ap.AddApprover(approver, "", false) 126 } 127 return ap.UnapprovedFiles() 128 } 129 130 // KeepCoveringApprovers finds who we should keep as suggested approvers given a pre-selection 131 // knownApprovers must be a subset of potentialApprovers. 132 func (o Owners) KeepCoveringApprovers(reverseMap map[string]sets.String, knownApprovers sets.String, potentialApprovers []string) sets.String { 133 keptApprovers := sets.NewString() 134 135 unapproved := o.temporaryUnapprovedFiles(knownApprovers) 136 137 for _, suggestedApprover := range o.GetSuggestedApprovers(reverseMap, potentialApprovers).List() { 138 if reverseMap[suggestedApprover].Intersection(unapproved).Len() != 0 { 139 keptApprovers.Insert(suggestedApprover) 140 } 141 } 142 143 return keptApprovers 144 } 145 146 // GetSuggestedApprovers solves the exact cover problem, finding an approver capable of 147 // approving every OWNERS file in the PR 148 func (o Owners) GetSuggestedApprovers(reverseMap map[string]sets.String, potentialApprovers []string) sets.String { 149 ap := NewApprovers(o) 150 for !ap.RequirementsMet() { 151 newApprover := findMostCoveringApprover(potentialApprovers, reverseMap, ap.UnapprovedFiles()) 152 if newApprover == "" { 153 o.log.Warnf("Couldn't find/suggest approvers for each files. Unapproved: %q", ap.UnapprovedFiles().List()) 154 return ap.GetCurrentApproversSet() 155 } 156 ap.AddApprover(newApprover, "", false) 157 } 158 159 return ap.GetCurrentApproversSet() 160 } 161 162 // GetOwnersSet returns a set containing all the Owners files necessary to get the PR approved 163 func (o Owners) GetOwnersSet() sets.String { 164 owners := sets.NewString() 165 for _, fn := range o.filenames { 166 owners.Insert(o.repo.FindApproverOwnersForFile(fn)) 167 } 168 o.removeSubdirs(owners) 169 return owners 170 } 171 172 // Shuffles the potential approvers so that we don't always suggest the same people 173 func (o Owners) GetShuffledApprovers() []string { 174 approversList := o.GetAllPotentialApprovers() 175 order := rand.New(rand.NewSource(o.seed)).Perm(len(approversList)) 176 people := make([]string, 0, len(approversList)) 177 for _, i := range order { 178 people = append(people, approversList[i]) 179 } 180 return people 181 } 182 183 // removeSubdirs takes a set of directories as an input and removes all subdirectories. 184 // E.g. [a, a/b/c, d/e, d/e/f] -> [a, d/e] 185 // Subdirs will not be removed if they are configured to have no parent OWNERS files or if any 186 // OWNERS file in the relative path between the subdir and the higher level dir is configured to 187 // have no parent OWNERS files. 188 func (o Owners) removeSubdirs(dirs sets.String) { 189 canonicalize := func(p string) string { 190 if p == "." { 191 return "" 192 } 193 return p 194 } 195 for _, dir := range dirs.List() { 196 path := dir 197 for { 198 if o.repo.IsNoParentOwners(path) || canonicalize(path) == "" { 199 break 200 } 201 path = filepath.Dir(path) 202 if dirs.Has(canonicalize(path)) { 203 dirs.Delete(dir) 204 break 205 } 206 } 207 } 208 } 209 210 // Approval has the information about each approval on a PR 211 type Approval struct { 212 Login string // Login of the approver (can include uppercase) 213 How string // How did the approver approved 214 Reference string // Where did the approver approved 215 NoIssue bool // Approval also accepts missing associated issue 216 } 217 218 // String creates a link for the approval. Use `Login` if you just want the name. 219 func (a Approval) String() string { 220 return fmt.Sprintf( 221 `*<a href="%s" title="%s">%s</a>*`, 222 a.Reference, 223 a.How, 224 a.Login, 225 ) 226 } 227 228 type Approvers struct { 229 owners Owners 230 approvers map[string]Approval // The keys of this map are normalized to lowercase. 231 assignees sets.String 232 AssociatedIssue int 233 RequireIssue bool 234 235 ManuallyApproved func() bool 236 } 237 238 // IntersectSetsCase runs the intersection between to sets.String in a 239 // case-insensitive way. It returns the name with the case of "one". 240 func IntersectSetsCase(one, other sets.String) sets.String { 241 lower := sets.NewString() 242 for item := range other { 243 lower.Insert(strings.ToLower(item)) 244 } 245 246 intersection := sets.NewString() 247 for item := range one { 248 if lower.Has(strings.ToLower(item)) { 249 intersection.Insert(item) 250 } 251 } 252 return intersection 253 } 254 255 // NewApprovers create a new "Approvers" with no approval. 256 func NewApprovers(owners Owners) Approvers { 257 return Approvers{ 258 owners: owners, 259 approvers: map[string]Approval{}, 260 assignees: sets.NewString(), 261 262 ManuallyApproved: func() bool { 263 return false 264 }, 265 } 266 } 267 268 // shouldNotOverrideApproval decides whether or not we should keep the 269 // original approval: 270 // If someone approves a PR multiple times, we only want to keep the 271 // latest approval, unless a previous approval was "no-issue", and the 272 // most recent isn't. 273 func (ap *Approvers) shouldNotOverrideApproval(login string, noIssue bool) bool { 274 login = strings.ToLower(login) 275 approval, alreadyApproved := ap.approvers[login] 276 277 return alreadyApproved && approval.NoIssue && !noIssue 278 } 279 280 // AddLGTMer adds a new LGTM Approver 281 func (ap *Approvers) AddLGTMer(login, reference string, noIssue bool) { 282 if ap.shouldNotOverrideApproval(login, noIssue) { 283 return 284 } 285 ap.approvers[strings.ToLower(login)] = Approval{ 286 Login: login, 287 How: "LGTM", 288 Reference: reference, 289 NoIssue: noIssue, 290 } 291 } 292 293 // AddApprover adds a new Approver 294 func (ap *Approvers) AddApprover(login, reference string, noIssue bool) { 295 if ap.shouldNotOverrideApproval(login, noIssue) { 296 return 297 } 298 ap.approvers[strings.ToLower(login)] = Approval{ 299 Login: login, 300 How: "Approved", 301 Reference: reference, 302 NoIssue: noIssue, 303 } 304 } 305 306 // AddSAuthorSelfApprover adds the author self approval 307 func (ap *Approvers) AddAuthorSelfApprover(login, reference string, noIssue bool) { 308 if ap.shouldNotOverrideApproval(login, noIssue) { 309 return 310 } 311 ap.approvers[strings.ToLower(login)] = Approval{ 312 Login: login, 313 How: "Author self-approved", 314 Reference: reference, 315 NoIssue: noIssue, 316 } 317 } 318 319 // RemoveApprover removes an approver from the list. 320 func (ap *Approvers) RemoveApprover(login string) { 321 delete(ap.approvers, strings.ToLower(login)) 322 } 323 324 // AddAssignees adds assignees to the list 325 func (ap *Approvers) AddAssignees(logins ...string) { 326 for _, login := range logins { 327 ap.assignees.Insert(strings.ToLower(login)) 328 } 329 } 330 331 // GetCurrentApproversSet returns the set of approvers (login only, normalized to lower case) 332 func (ap Approvers) GetCurrentApproversSet() sets.String { 333 currentApprovers := sets.NewString() 334 335 for approver := range ap.approvers { 336 currentApprovers.Insert(approver) 337 } 338 339 return currentApprovers 340 } 341 342 // GetCurrentApproversSetCased returns the set of approvers logins with the original cases. 343 func (ap Approvers) GetCurrentApproversSetCased() sets.String { 344 currentApprovers := sets.NewString() 345 346 for _, approval := range ap.approvers { 347 currentApprovers.Insert(approval.Login) 348 } 349 350 return currentApprovers 351 } 352 353 // GetNoIssueApproversSet returns the set of "no-issue" approvers (login 354 // only) 355 func (ap Approvers) GetNoIssueApproversSet() sets.String { 356 approvers := sets.NewString() 357 358 for approver := range ap.NoIssueApprovers() { 359 approvers.Insert(approver) 360 } 361 362 return approvers 363 } 364 365 // GetFilesApprovers returns a map from files -> list of current approvers. 366 func (ap Approvers) GetFilesApprovers() map[string]sets.String { 367 filesApprovers := map[string]sets.String{} 368 currentApprovers := ap.GetCurrentApproversSetCased() 369 for fn, potentialApprovers := range ap.owners.GetApprovers() { 370 // The order of parameter matters here: 371 // - currentApprovers is the list of github handles that have approved 372 // - potentialApprovers is the list of handles in the OWNER 373 // files (lower case). 374 // 375 // We want to keep the syntax of the github handle 376 // rather than the potential mis-cased username found in 377 // the OWNERS file, that's why it's the first parameter. 378 filesApprovers[fn] = IntersectSetsCase(currentApprovers, potentialApprovers) 379 } 380 381 return filesApprovers 382 } 383 384 // NoIssueApprovers returns the list of people who have "no-issue" 385 // approved the pull-request. They are included in the list iff they can 386 // approve one of the files. 387 func (ap Approvers) NoIssueApprovers() map[string]Approval { 388 nia := map[string]Approval{} 389 reverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers()) 390 391 for login, approver := range ap.approvers { 392 if !approver.NoIssue { 393 continue 394 } 395 396 if len(reverseMap[login]) == 0 { 397 continue 398 } 399 400 nia[login] = approver 401 } 402 403 return nia 404 } 405 406 // UnapprovedFiles returns owners files that still need approval 407 func (ap Approvers) UnapprovedFiles() sets.String { 408 unapproved := sets.NewString() 409 for fn, approvers := range ap.GetFilesApprovers() { 410 if len(approvers) == 0 { 411 unapproved.Insert(fn) 412 } 413 } 414 return unapproved 415 } 416 417 // UnapprovedFiles returns owners files that still need approval 418 func (ap Approvers) GetFiles(org, project, branch string) []File { 419 allOwnersFiles := []File{} 420 filesApprovers := ap.GetFilesApprovers() 421 for _, fn := range ap.owners.GetOwnersSet().List() { 422 if len(filesApprovers[fn]) == 0 { 423 allOwnersFiles = append(allOwnersFiles, UnapprovedFile{ 424 filepath: fn, 425 org: org, 426 project: project, 427 branch: branch, 428 }) 429 } else { 430 allOwnersFiles = append(allOwnersFiles, ApprovedFile{ 431 filepath: fn, 432 approvers: filesApprovers[fn], 433 org: org, 434 project: project, 435 branch: branch, 436 }) 437 } 438 } 439 440 return allOwnersFiles 441 } 442 443 // GetCCs gets the list of suggested approvers for a pull-request. It 444 // now considers current assignees as potential approvers. Here is how 445 // it works: 446 // - We find suggested approvers from all potential approvers, but 447 // remove those that are not useful considering current approvers and 448 // assignees. This only uses leave approvers to find approvers the 449 // closest to the changes. 450 // - We find a subset of suggested approvers from from current 451 // approvers, suggested approvers and assignees, but we remove thoses 452 // that are not useful considering suggestd approvers and current 453 // approvers. This uses the full approvers list, and will result in root 454 // approvers to be suggested when they are assigned. 455 // We return the union of the two sets: suggested and suggested 456 // assignees. 457 // The goal of this second step is to only keep the assignees that are 458 // the most useful. 459 func (ap Approvers) GetCCs() []string { 460 randomizedApprovers := ap.owners.GetShuffledApprovers() 461 462 currentApprovers := ap.GetCurrentApproversSet() 463 approversAndAssignees := currentApprovers.Union(ap.assignees) 464 leafReverseMap := ap.owners.GetReverseMap(ap.owners.GetLeafApprovers()) 465 suggested := ap.owners.KeepCoveringApprovers(leafReverseMap, approversAndAssignees, randomizedApprovers) 466 approversAndSuggested := currentApprovers.Union(suggested) 467 everyone := approversAndSuggested.Union(ap.assignees) 468 fullReverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers()) 469 keepAssignees := ap.owners.KeepCoveringApprovers(fullReverseMap, approversAndSuggested, everyone.List()) 470 471 return suggested.Union(keepAssignees).List() 472 } 473 474 // AreFilesApproved returns a bool indicating whether or not OWNERS files associated with 475 // the PR are approved. If this returns true, the PR may still not be fully approved depending 476 // on the associated issue requirement 477 func (ap Approvers) AreFilesApproved() bool { 478 return ap.UnapprovedFiles().Len() == 0 479 } 480 481 // RequirementsMet returns a bool indicating whether the PR has met all approval requirements: 482 // - all OWNERS files associated with the PR have been approved AND 483 // EITHER 484 // - the munger config is such that an issue is not required to be associated with the PR 485 // - that there is an associated issue with the PR 486 // - an OWNER has indicated that the PR is trivial enough that an issue need not be associated with the PR 487 func (ap Approvers) RequirementsMet() bool { 488 return ap.AreFilesApproved() && (!ap.RequireIssue || ap.AssociatedIssue != 0 || len(ap.NoIssueApprovers()) != 0) 489 } 490 491 // IsApproved returns a bool indicating whether the PR is fully approved. 492 // If a human manually added the approved label, this returns true, ignoring normal approval rules. 493 func (ap Approvers) IsApproved() bool { 494 reqsMet := ap.RequirementsMet() 495 if !reqsMet && ap.ManuallyApproved() { 496 return true 497 } 498 return reqsMet 499 } 500 501 // ListApprovals returns the list of approvals 502 func (ap Approvers) ListApprovals() []Approval { 503 approvals := []Approval{} 504 505 for _, approver := range ap.GetCurrentApproversSet().List() { 506 approvals = append(approvals, ap.approvers[approver]) 507 } 508 509 return approvals 510 } 511 512 // ListNoIssueApprovals returns the list of "no-issue" approvals 513 func (ap Approvers) ListNoIssueApprovals() []Approval { 514 approvals := []Approval{} 515 516 for _, approver := range ap.GetNoIssueApproversSet().List() { 517 approvals = append(approvals, ap.approvers[approver]) 518 } 519 520 return approvals 521 } 522 523 type File interface { 524 String() string 525 } 526 527 type ApprovedFile struct { 528 filepath string 529 approvers sets.String 530 org string 531 project string 532 branch string 533 } 534 535 type UnapprovedFile struct { 536 filepath string 537 org string 538 project string 539 branch string 540 } 541 542 func (a ApprovedFile) String() string { 543 fullOwnersPath := filepath.Join(a.filepath, ownersFileName) 544 if strings.HasSuffix(a.filepath, ".md") { 545 fullOwnersPath = a.filepath 546 } 547 link := fmt.Sprintf("https://github.com/%s/%s/blob/%s/%v", a.org, a.project, a.branch, fullOwnersPath) 548 return fmt.Sprintf("- ~~[%s](%s)~~ [%v]\n", fullOwnersPath, link, strings.Join(a.approvers.List(), ",")) 549 } 550 551 func (ua UnapprovedFile) String() string { 552 fullOwnersPath := filepath.Join(ua.filepath, ownersFileName) 553 if strings.HasSuffix(ua.filepath, ".md") { 554 fullOwnersPath = ua.filepath 555 } 556 link := fmt.Sprintf("https://github.com/%s/%s/blob/%s/%v", ua.org, ua.project, ua.branch, fullOwnersPath) 557 return fmt.Sprintf("- **[%s](%s)**\n", fullOwnersPath, link) 558 } 559 560 // GenerateTemplate takes a template, name and data, and generates 561 // the corresponding string. 562 func GenerateTemplate(templ, name string, data interface{}) (string, error) { 563 buf := bytes.NewBufferString("") 564 if messageTempl, err := template.New(name).Parse(templ); err != nil { 565 return "", fmt.Errorf("failed to parse template for %s: %v", name, err) 566 } else if err := messageTempl.Execute(buf, data); err != nil { 567 return "", fmt.Errorf("failed to execute template for %s: %v", name, err) 568 } 569 return buf.String(), nil 570 } 571 572 // getMessage returns the comment body that we want the approve plugin to display on PRs 573 // The comment shows: 574 // - a list of approvers files (and links) needed to get the PR approved 575 // - a list of approvers files with strikethroughs that already have an approver's approval 576 // - a suggested list of people from each OWNERS files that can fully approve the PR 577 // - how an approver can indicate their approval 578 // - how an approver can cancel their approval 579 func GetMessage(ap Approvers, org, project, branch string) *string { 580 message, err := GenerateTemplate(`{{if (and (not .ap.RequirementsMet) (call .ap.ManuallyApproved )) }} 581 Approval requirements bypassed by manually added approval. 582 583 {{end -}} 584 This pull-request has been approved by:{{range $index, $approval := .ap.ListApprovals}}{{if $index}}, {{else}} {{end}}{{$approval}}{{end}} 585 586 {{- if (and (not .ap.AreFilesApproved) (not (call .ap.ManuallyApproved))) }} 587 To fully approve this pull request, please assign additional approvers. 588 We suggest the following additional approver{{if ne 1 (len .ap.GetCCs)}}s{{end}}: {{range $index, $cc := .ap.GetCCs}}{{if $index}}, {{end}}**{{$cc}}**{{end}} 589 590 If they are not already assigned, you can assign the PR to them by writing `+"`/assign {{range $index, $cc := .ap.GetCCs}}{{if $index}} {{end}}@{{$cc}}{{end}}`"+` in a comment when ready. 591 {{- end}} 592 593 {{if not .ap.RequireIssue -}} 594 {{else if .ap.AssociatedIssue -}} 595 Associated issue: *#{{.ap.AssociatedIssue}}* 596 597 {{ else if len .ap.NoIssueApprovers -}} 598 Associated issue requirement bypassed by:{{range $index, $approval := .ap.ListNoIssueApprovals}}{{if $index}}, {{else}} {{end}}{{$approval}}{{end}} 599 600 {{ else if call .ap.ManuallyApproved -}} 601 *No associated issue*. Requirement bypassed by manually added approval. 602 603 {{ else -}} 604 *No associated issue*. Update pull-request body to add a reference to an issue, or get approval with `+"`/approve no-issue`"+` 605 606 {{ end -}} 607 608 The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands). 609 610 The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process) 611 612 <details {{if (and (not .ap.AreFilesApproved) (not (call .ap.ManuallyApproved))) }}open{{end}}> 613 Needs approval from an approver in each of these files: 614 615 {{range .ap.GetFiles .org .project .branch}}{{.}}{{end}} 616 Approvers can indicate their approval by writing `+"`/approve`"+` in a comment 617 Approvers can cancel approval by writing `+"`/approve cancel`"+` in a comment 618 </details>`, "message", map[string]interface{}{"ap": ap, "org": org, "project": project, "branch": branch}) 619 if err != nil { 620 ap.owners.log.WithError(err).Errorf("Error generating message.") 621 return nil 622 } 623 message += getGubernatorMetadata(ap.GetCCs()) 624 625 title, err := GenerateTemplate("This PR is **{{if not .IsApproved}}NOT {{end}}APPROVED**", "title", ap) 626 if err != nil { 627 ap.owners.log.WithError(err).Errorf("Error generating title.") 628 return nil 629 } 630 631 return notification(ApprovalNotificationName, title, message) 632 } 633 634 func notification(name, arguments, context string) *string { 635 str := "[" + strings.ToUpper(name) + "]" 636 637 args := strings.TrimSpace(arguments) 638 if args != "" { 639 str += " " + args 640 } 641 642 ctx := strings.TrimSpace(context) 643 if ctx != "" { 644 str += "\n\n" + ctx 645 } 646 647 return &str 648 } 649 650 // getGubernatorMetadata returns a JSON string with machine-readable information about approvers. 651 // This MUST be kept in sync with gubernator/github/classifier.py, particularly get_approvers. 652 func getGubernatorMetadata(toBeAssigned []string) string { 653 bytes, err := json.Marshal(map[string][]string{"approvers": toBeAssigned}) 654 if err == nil { 655 return fmt.Sprintf("\n<!-- META=%s -->", bytes) 656 } 657 return "" 658 }