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