github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/prow/repoowners/repoowners.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 repoowners 18 19 import ( 20 "fmt" 21 "io/ioutil" 22 "os" 23 "path/filepath" 24 "regexp" 25 "strings" 26 "sync" 27 28 "github.com/ghodss/yaml" 29 "github.com/sirupsen/logrus" 30 31 "k8s.io/apimachinery/pkg/util/sets" 32 "k8s.io/test-infra/prow/git" 33 "k8s.io/test-infra/prow/github" 34 35 prowConf "k8s.io/test-infra/prow/config" 36 ) 37 38 const ( 39 ownersFileName = "OWNERS" 40 aliasesFileName = "OWNERS_ALIASES" 41 // Github's api uses "" (empty) string as basedir by convention but it's clearer to use "/" 42 baseDirConvention = "" 43 ) 44 45 var defaultDirBlacklist = sets.NewString(".git", "_output") 46 47 type dirOptions struct { 48 NoParentOwners bool `json:"no_parent_owners,omitempty"` 49 } 50 51 // Config holds roles+usernames and labels for a directory considered as a unit of independent code 52 type Config struct { 53 Approvers []string `json:"approvers,omitempty"` 54 Reviewers []string `json:"reviewers,omitempty"` 55 RequiredReviewers []string `json:"required_reviewers,omitempty"` 56 Labels []string `json:"labels,omitempty"` 57 } 58 59 // SimpleConfig holds options and Config applied to everything under the containing directory 60 type SimpleConfig struct { 61 Options dirOptions `json:"options,omitempty"` 62 Config `json:",inline"` 63 } 64 65 // Empty checks if a SimpleConfig could be considered empty 66 func (s *SimpleConfig) Empty() bool { 67 return len(s.Approvers) == 0 && len(s.Reviewers) == 0 && len(s.RequiredReviewers) == 0 && len(s.Labels) == 0 68 } 69 70 // FullConfig contains Filters which apply specific Config to files matching its regexp 71 type FullConfig struct { 72 Options dirOptions `json:"options,omitempty"` 73 Filters map[string]Config `json:"filters,omitempty"` 74 } 75 76 type githubClient interface { 77 ListCollaborators(org, repo string) ([]github.User, error) 78 GetRef(org, repo, ref string) (string, error) 79 } 80 81 type cacheEntry struct { 82 sha string 83 aliases RepoAliases 84 owners *RepoOwners 85 } 86 87 type configGetter interface { 88 Config() *prowConf.Config 89 } 90 91 // Interface is an interface to work with OWNERS files. 92 type Interface interface { 93 LoadRepoAliases(org, repo, base string) (RepoAliases, error) 94 LoadRepoOwners(org, repo, base string) (RepoOwnerInterface, error) 95 } 96 97 // Client is an implementation of the Interface. 98 var _ Interface = &Client{} 99 100 // Client is the repoowners client 101 type Client struct { 102 configGetter configGetter 103 104 git *git.Client 105 ghc githubClient 106 logger *logrus.Entry 107 108 mdYAMLEnabled func(org, repo string) bool 109 skipCollaborators func(org, repo string) bool 110 111 lock sync.Mutex 112 cache map[string]cacheEntry 113 } 114 115 // NewClient is the constructor for Client 116 func NewClient( 117 gc *git.Client, 118 ghc *github.Client, 119 configGetter *prowConf.Agent, 120 mdYAMLEnabled func(org, repo string) bool, 121 skipCollaborators func(org, repo string) bool, 122 ) *Client { 123 return &Client{ 124 git: gc, 125 ghc: ghc, 126 logger: logrus.WithField("client", "repoowners"), 127 cache: make(map[string]cacheEntry), 128 129 mdYAMLEnabled: mdYAMLEnabled, 130 skipCollaborators: skipCollaborators, 131 132 configGetter: configGetter, 133 } 134 } 135 136 // RepoAliases defines groups of people to be used in OWNERS files 137 type RepoAliases map[string]sets.String 138 139 // RepoOwnerInterface is an interface to work with repoowners 140 type RepoOwnerInterface interface { 141 FindApproverOwnersForFile(path string) string 142 FindReviewersOwnersForFile(path string) string 143 FindLabelsForFile(path string) sets.String 144 IsNoParentOwners(path string) bool 145 LeafApprovers(path string) sets.String 146 Approvers(path string) sets.String 147 LeafReviewers(path string) sets.String 148 Reviewers(path string) sets.String 149 RequiredReviewers(path string) sets.String 150 } 151 152 // RepoOwners implements RepoOwnerInterface 153 var _ RepoOwnerInterface = &RepoOwners{} 154 155 // RepoOwners contains the parsed OWNERS config 156 type RepoOwners struct { 157 RepoAliases 158 159 approvers map[string]map[*regexp.Regexp]sets.String 160 reviewers map[string]map[*regexp.Regexp]sets.String 161 requiredReviewers map[string]map[*regexp.Regexp]sets.String 162 labels map[string]map[*regexp.Regexp]sets.String 163 options map[string]dirOptions 164 165 baseDir string 166 enableMDYAML bool 167 dirBlacklist sets.String 168 169 log *logrus.Entry 170 } 171 172 // LoadRepoAliases returns an up-to-date RepoAliases struct for the specified repo. 173 // If the repo does not have an aliases file then an empty alias map is returned with no error. 174 // Note: The returned RepoAliases should be treated as read only. 175 func (c *Client) LoadRepoAliases(org, repo, base string) (RepoAliases, error) { 176 log := c.logger.WithFields(logrus.Fields{"org": org, "repo": repo, "base": base}) 177 cloneRef := fmt.Sprintf("%s/%s", org, repo) 178 fullName := fmt.Sprintf("%s:%s", cloneRef, base) 179 180 sha, err := c.ghc.GetRef(org, repo, fmt.Sprintf("heads/%s", base)) 181 if err != nil { 182 return nil, fmt.Errorf("failed to get current SHA for %s: %v", fullName, err) 183 } 184 185 c.lock.Lock() 186 defer c.lock.Unlock() 187 entry, ok := c.cache[fullName] 188 if !ok || entry.sha != sha { 189 // entry is non-existent or stale. 190 gitRepo, err := c.git.Clone(cloneRef) 191 if err != nil { 192 return nil, fmt.Errorf("failed to clone %s: %v", cloneRef, err) 193 } 194 defer gitRepo.Clean() 195 if err := gitRepo.Checkout(base); err != nil { 196 return nil, err 197 } 198 199 entry.aliases = loadAliasesFrom(gitRepo.Dir, log) 200 entry.sha = sha 201 c.cache[fullName] = entry 202 } 203 204 return entry.aliases, nil 205 } 206 207 // LoadRepoOwners returns an up-to-date RepoOwners struct for the specified repo. 208 // Note: The returned *RepoOwners should be treated as read only. 209 func (c *Client) LoadRepoOwners(org, repo, base string) (RepoOwnerInterface, error) { 210 log := c.logger.WithFields(logrus.Fields{"org": org, "repo": repo, "base": base}) 211 cloneRef := fmt.Sprintf("%s/%s", org, repo) 212 fullName := fmt.Sprintf("%s:%s", cloneRef, base) 213 mdYaml := c.mdYAMLEnabled(org, repo) 214 215 sha, err := c.ghc.GetRef(org, repo, fmt.Sprintf("heads/%s", base)) 216 if err != nil { 217 return nil, fmt.Errorf("failed to get current SHA for %s: %v", fullName, err) 218 } 219 220 c.lock.Lock() 221 defer c.lock.Unlock() 222 entry, ok := c.cache[fullName] 223 if !ok || entry.sha != sha || entry.owners == nil || entry.owners.enableMDYAML != mdYaml { 224 gitRepo, err := c.git.Clone(cloneRef) 225 if err != nil { 226 return nil, fmt.Errorf("failed to clone %s: %v", cloneRef, err) 227 } 228 defer gitRepo.Clean() 229 if err := gitRepo.Checkout(base); err != nil { 230 return nil, err 231 } 232 233 if entry.aliases == nil || entry.sha != sha { 234 // aliases must be loaded 235 entry.aliases = loadAliasesFrom(gitRepo.Dir, log) 236 } 237 238 blacklistConfig := c.configGetter.Config().OwnersDirBlacklist 239 240 dirBlacklist := defaultDirBlacklist.Union(sets.NewString(blacklistConfig.Default...)) 241 if bl, ok := blacklistConfig.Repos[org]; ok { 242 dirBlacklist.Insert(bl...) 243 } 244 if bl, ok := blacklistConfig.Repos[org+"/"+repo]; ok { 245 dirBlacklist.Insert(bl...) 246 } 247 entry.owners, err = loadOwnersFrom(gitRepo.Dir, mdYaml, entry.aliases, dirBlacklist, log) 248 if err != nil { 249 return nil, fmt.Errorf("failed to load RepoOwners for %s: %v", fullName, err) 250 } 251 entry.sha = sha 252 c.cache[fullName] = entry 253 } 254 255 if c.skipCollaborators(org, repo) { 256 log.Debugf("Skipping collaborator checks for %s/%s", org, repo) 257 return entry.owners, nil 258 } 259 260 var owners *RepoOwners 261 // Filter collaborators. We must filter the RepoOwners struct even if it came from the cache 262 // because the list of collaborators could have changed without the git SHA changing. 263 collaborators, err := c.ghc.ListCollaborators(org, repo) 264 if err != nil { 265 log.WithError(err).Errorf("Failed to list collaborators while loading RepoOwners. Skipping collaborator filtering.") 266 owners = entry.owners 267 } else { 268 owners = entry.owners.filterCollaborators(collaborators) 269 } 270 return owners, nil 271 } 272 273 // ExpandAlias returns members of an alias 274 func (a RepoAliases) ExpandAlias(alias string) sets.String { 275 if a == nil { 276 return nil 277 } 278 return a[github.NormLogin(alias)] 279 } 280 281 // ExpandAliases returns members of multiple aliases, duplicates are pruned 282 func (a RepoAliases) ExpandAliases(logins sets.String) sets.String { 283 if a == nil { 284 return logins 285 } 286 // Make logins a copy of the original set to avoid modifying the original. 287 logins = logins.Union(nil) 288 for _, login := range logins.List() { 289 if expanded := a.ExpandAlias(login); len(expanded) > 0 { 290 logins.Delete(login) 291 logins = logins.Union(expanded) 292 } 293 } 294 return logins 295 } 296 297 func loadAliasesFrom(baseDir string, log *logrus.Entry) RepoAliases { 298 path := filepath.Join(baseDir, aliasesFileName) 299 b, err := ioutil.ReadFile(path) 300 if os.IsNotExist(err) { 301 log.WithError(err).Infof("No alias file exists at %q. Using empty alias map.", path) 302 return nil 303 } else if err != nil { 304 log.WithError(err).Warnf("Failed to read alias file %q. Using empty alias map.", path) 305 return nil 306 } 307 config := &struct { 308 Data map[string][]string `json:"aliases,omitempty"` 309 }{} 310 if err := yaml.Unmarshal(b, config); err != nil { 311 log.WithError(err).Errorf("Failed to unmarshal aliases from %q. Using empty alias map.", path) 312 return nil 313 } 314 315 result := make(RepoAliases) 316 for alias, expanded := range config.Data { 317 result[github.NormLogin(alias)] = normLogins(expanded) 318 } 319 log.Infof("Loaded %d aliases from %q.", len(result), path) 320 return result 321 } 322 323 func loadOwnersFrom(baseDir string, mdYaml bool, aliases RepoAliases, dirBlacklist sets.String, log *logrus.Entry) (*RepoOwners, error) { 324 o := &RepoOwners{ 325 RepoAliases: aliases, 326 baseDir: baseDir, 327 enableMDYAML: mdYaml, 328 log: log, 329 330 approvers: make(map[string]map[*regexp.Regexp]sets.String), 331 reviewers: make(map[string]map[*regexp.Regexp]sets.String), 332 requiredReviewers: make(map[string]map[*regexp.Regexp]sets.String), 333 labels: make(map[string]map[*regexp.Regexp]sets.String), 334 options: make(map[string]dirOptions), 335 336 dirBlacklist: dirBlacklist, 337 } 338 339 return o, filepath.Walk(o.baseDir, o.walkFunc) 340 } 341 342 // by default, github's api doesn't root the project directory at "/" and instead uses the empty string for the base dir 343 // of the project. And the built-in dir function returns "." for empty strings, so for consistency, we use this 344 // canonicalize to get the directories of files in a consistent format with NO "/" at the root (a/b/c/ -> a/b/c) 345 func canonicalize(path string) string { 346 if path == "." { 347 return baseDirConvention 348 } 349 return strings.TrimSuffix(path, "/") 350 } 351 352 func (o *RepoOwners) walkFunc(path string, info os.FileInfo, err error) error { 353 log := o.log.WithField("path", path) 354 if err != nil { 355 log.WithError(err).Error("Error while walking OWNERS files.") 356 return nil 357 } 358 filename := filepath.Base(path) 359 360 if info.Mode().IsDir() && o.dirBlacklist.Has(filename) { 361 return filepath.SkipDir 362 } 363 if !info.Mode().IsRegular() { 364 return nil 365 } 366 367 // '.md' files may contain assignees at the top of the file in a yaml header 368 // Note that these assignees only apply to the file itself. 369 if o.enableMDYAML && strings.HasSuffix(filename, ".md") { 370 // Parse the yaml header from the file if it exists and marshal into the config 371 simple := &SimpleConfig{} 372 if err := decodeOwnersMdConfig(path, simple); err != nil { 373 log.WithError(err).Error("Error decoding OWNERS config from '*.md' file.") 374 return nil 375 } 376 377 // Set owners for this file (not the directory) using the relative path if they were found 378 relPath, err := filepath.Rel(o.baseDir, path) 379 if err != nil { 380 log.WithError(err).Errorf("Unable to find relative path between baseDir: %q and path.", o.baseDir) 381 return err 382 } 383 o.applyConfigToPath(relPath, nil, &simple.Config) 384 o.applyOptionsToPath(relPath, simple.Options) 385 return nil 386 } 387 388 if filename != ownersFileName { 389 return nil 390 } 391 392 b, err := ioutil.ReadFile(path) 393 if err != nil { 394 log.WithError(err).Errorf("Failed to read the OWNERS file.") 395 return nil 396 } 397 398 relPath, err := filepath.Rel(o.baseDir, path) 399 if err != nil { 400 log.WithError(err).Errorf("Unable to find relative path between baseDir: %q and path.", o.baseDir) 401 return err 402 } 403 relPathDir := canonicalize(filepath.Dir(relPath)) 404 405 simple, err := ParseSimpleConfig(b) 406 if err != nil || simple.Empty() { 407 c, err := ParseFullConfig(b) 408 if err != nil { 409 log.WithError(err).Errorf("Failed to unmarshal %s into either Simple or FullConfig.", path) 410 } else { 411 // it's a FullConfig 412 for pattern, config := range c.Filters { 413 var re *regexp.Regexp 414 if pattern != ".*" { 415 if re, err = regexp.Compile(pattern); err != nil { 416 log.WithError(err).Errorf("Invalid regexp %q.", pattern) 417 continue 418 } 419 } 420 o.applyConfigToPath(relPathDir, re, &config) 421 } 422 o.applyOptionsToPath(relPathDir, c.Options) 423 } 424 } else { 425 // it's a SimpleConfig 426 o.applyConfigToPath(relPathDir, nil, &simple.Config) 427 o.applyOptionsToPath(relPathDir, simple.Options) 428 } 429 return nil 430 } 431 432 // ParseFullConfig will unmarshal OWNERS file's content into a FullConfig 433 // Returns an error if the content cannot be unmarshalled 434 func ParseFullConfig(b []byte) (FullConfig, error) { 435 full := new(FullConfig) 436 err := yaml.Unmarshal(b, full) 437 return *full, err 438 } 439 440 // ParseSimpleConfig will unmarshal an OWNERS file's content into a SimpleConfig 441 // Returns an error if the content cannot be unmarshalled 442 func ParseSimpleConfig(b []byte) (SimpleConfig, error) { 443 simple := new(SimpleConfig) 444 err := yaml.Unmarshal(b, simple) 445 return *simple, err 446 } 447 448 var mdStructuredHeaderRegex = regexp.MustCompile("^---\n(.|\n)*\n---") 449 450 // decodeOwnersMdConfig will parse the yaml header if it exists and unmarshal it into a singleOwnersConfig. 451 // If no yaml header is found, do nothing 452 // Returns an error if the file cannot be read or the yaml header is found but cannot be unmarshalled. 453 func decodeOwnersMdConfig(path string, config *SimpleConfig) error { 454 fileBytes, err := ioutil.ReadFile(path) 455 if err != nil { 456 return err 457 } 458 // Parse the yaml header from the top of the file. Will return an empty string if regex does not match. 459 meta := mdStructuredHeaderRegex.FindString(string(fileBytes)) 460 461 // Unmarshal the yaml header into the config 462 return yaml.Unmarshal([]byte(meta), &config) 463 } 464 465 func normLogins(logins []string) sets.String { 466 normed := sets.NewString() 467 for _, login := range logins { 468 normed.Insert(github.NormLogin(login)) 469 } 470 return normed 471 } 472 473 var defaultDirOptions = dirOptions{} 474 475 func (o *RepoOwners) applyConfigToPath(path string, re *regexp.Regexp, config *Config) { 476 if len(config.Approvers) > 0 { 477 if o.approvers[path] == nil { 478 o.approvers[path] = make(map[*regexp.Regexp]sets.String) 479 } 480 o.approvers[path][re] = o.ExpandAliases(normLogins(config.Approvers)) 481 } 482 if len(config.Reviewers) > 0 { 483 if o.reviewers[path] == nil { 484 o.reviewers[path] = make(map[*regexp.Regexp]sets.String) 485 } 486 o.reviewers[path][re] = o.ExpandAliases(normLogins(config.Reviewers)) 487 } 488 if len(config.RequiredReviewers) > 0 { 489 if o.requiredReviewers[path] == nil { 490 o.requiredReviewers[path] = make(map[*regexp.Regexp]sets.String) 491 } 492 o.requiredReviewers[path][re] = o.ExpandAliases(normLogins(config.RequiredReviewers)) 493 } 494 if len(config.Labels) > 0 { 495 if o.labels[path] == nil { 496 o.labels[path] = make(map[*regexp.Regexp]sets.String) 497 } 498 o.labels[path][re] = sets.NewString(config.Labels...) 499 } 500 } 501 502 func (o *RepoOwners) applyOptionsToPath(path string, opts dirOptions) { 503 if opts != defaultDirOptions { 504 o.options[path] = opts 505 } 506 } 507 508 func (o *RepoOwners) filterCollaborators(toKeep []github.User) *RepoOwners { 509 collabs := sets.NewString() 510 for _, keeper := range toKeep { 511 collabs.Insert(github.NormLogin(keeper.Login)) 512 } 513 514 filter := func(ownerMap map[string]map[*regexp.Regexp]sets.String) map[string]map[*regexp.Regexp]sets.String { 515 filtered := make(map[string]map[*regexp.Regexp]sets.String) 516 for path, reMap := range ownerMap { 517 filtered[path] = make(map[*regexp.Regexp]sets.String) 518 for re, unfiltered := range reMap { 519 filtered[path][re] = unfiltered.Intersection(collabs) 520 } 521 } 522 return filtered 523 } 524 525 result := *o 526 result.approvers = filter(o.approvers) 527 result.reviewers = filter(o.reviewers) 528 return &result 529 } 530 531 // findOwnersForFile returns the OWNERS file path furthest down the tree for a specified file 532 // using ownerMap to check for entries 533 func findOwnersForFile(log *logrus.Entry, path string, ownerMap map[string]map[*regexp.Regexp]sets.String) string { 534 d := path 535 536 for ; d != baseDirConvention; d = canonicalize(filepath.Dir(d)) { 537 relative, err := filepath.Rel(d, path) 538 if err != nil { 539 log.WithError(err).WithField("path", path).Errorf("Unable to find relative path between %q and path.", d) 540 return "" 541 } 542 for re, n := range ownerMap[d] { 543 if re != nil && !re.MatchString(relative) { 544 continue 545 } 546 if len(n) != 0 { 547 return d 548 } 549 } 550 } 551 return "" 552 } 553 554 // FindApproverOwnersForFile returns the OWNERS file path furthest down the tree for a specified file 555 // that contains an approvers section 556 func (o *RepoOwners) FindApproverOwnersForFile(path string) string { 557 return findOwnersForFile(o.log, path, o.approvers) 558 } 559 560 // FindReviewersOwnersForFile returns the OWNERS file path furthest down the tree for a specified file 561 // that contains a reviewers section 562 func (o *RepoOwners) FindReviewersOwnersForFile(path string) string { 563 return findOwnersForFile(o.log, path, o.reviewers) 564 } 565 566 // FindLabelsForFile returns a set of labels which should be applied to PRs 567 // modifying files under the given path. 568 func (o *RepoOwners) FindLabelsForFile(path string) sets.String { 569 return o.entriesForFile(path, o.labels, false) 570 } 571 572 // IsNoParentOwners checks if an OWNERS file path refers to an OWNERS file with NoParentOwners enabled. 573 func (o *RepoOwners) IsNoParentOwners(path string) bool { 574 return o.options[path].NoParentOwners 575 } 576 577 // entriesForFile returns a set of users who are assignees to the 578 // requested file. The path variable should be a full path to a filename 579 // and not directory as the final directory will be discounted if enableMDYAML is true 580 // leafOnly indicates whether only the OWNERS deepest in the tree (closest to the file) 581 // should be returned or if all OWNERS in filepath should be returned 582 func (o *RepoOwners) entriesForFile(path string, people map[string]map[*regexp.Regexp]sets.String, leafOnly bool) sets.String { 583 d := path 584 if !o.enableMDYAML || !strings.HasSuffix(path, ".md") { 585 // if path is a directory, this will remove the leaf directory, and returns "." for topmost dir 586 d = filepath.Dir(d) 587 d = canonicalize(path) 588 } 589 590 out := sets.NewString() 591 for { 592 relative, err := filepath.Rel(d, path) 593 if err != nil { 594 o.log.WithError(err).WithField("path", path).Errorf("Unable to find relative path between %q and path.", d) 595 return nil 596 } 597 for re, s := range people[d] { 598 if re == nil || re.MatchString(relative) { 599 out.Insert(s.List()...) 600 } 601 } 602 if leafOnly && out.Len() > 0 { 603 break 604 } 605 if d == baseDirConvention { 606 break 607 } 608 if o.options[d].NoParentOwners { 609 break 610 } 611 d = filepath.Dir(d) 612 d = canonicalize(d) 613 } 614 return out 615 } 616 617 // LeafApprovers returns a set of users who are the closest approvers to the 618 // requested file. If pkg/OWNERS has user1 and pkg/util/OWNERS has user2 this 619 // will only return user2 for the path pkg/util/sets/file.go 620 func (o *RepoOwners) LeafApprovers(path string) sets.String { 621 return o.entriesForFile(path, o.approvers, true) 622 } 623 624 // Approvers returns ALL of the users who are approvers for the 625 // requested file (including approvers in parent dirs' OWNERS). 626 // If pkg/OWNERS has user1 and pkg/util/OWNERS has user2 this 627 // will return both user1 and user2 for the path pkg/util/sets/file.go 628 func (o *RepoOwners) Approvers(path string) sets.String { 629 return o.entriesForFile(path, o.approvers, false) 630 } 631 632 // LeafReviewers returns a set of users who are the closest reviewers to the 633 // requested file. If pkg/OWNERS has user1 and pkg/util/OWNERS has user2 this 634 // will only return user2 for the path pkg/util/sets/file.go 635 func (o *RepoOwners) LeafReviewers(path string) sets.String { 636 return o.entriesForFile(path, o.reviewers, true) 637 } 638 639 // Reviewers returns ALL of the users who are reviewers for the 640 // requested file (including reviewers in parent dirs' OWNERS). 641 // If pkg/OWNERS has user1 and pkg/util/OWNERS has user2 this 642 // will return both user1 and user2 for the path pkg/util/sets/file.go 643 func (o *RepoOwners) Reviewers(path string) sets.String { 644 return o.entriesForFile(path, o.reviewers, false) 645 } 646 647 // RequiredReviewers returns ALL of the users who are required_reviewers for the 648 // requested file (including required_reviewers in parent dirs' OWNERS). 649 // If pkg/OWNERS has user1 and pkg/util/OWNERS has user2 this 650 // will return both user1 and user2 for the path pkg/util/sets/file.go 651 func (o *RepoOwners) RequiredReviewers(path string) sets.String { 652 return o.entriesForFile(path, o.requiredReviewers, false) 653 }