dev/codeowners/codeowners.go (198 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.
package codeowners
import (
"bufio"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const DefaultCodeownersPath = ".github/CODEOWNERS"
func Check() error {
codeowners, err := readGithubOwners(DefaultCodeownersPath)
if err != nil {
return err
}
const packagesDir = "packages"
if err := validatePackages(codeowners, packagesDir); err != nil {
return err
}
return nil
}
func PackageOwners(packageName, dataStream, codeownersPath string) ([]string, error) {
owners, err := readGithubOwners(codeownersPath)
if err != nil {
return nil, fmt.Errorf("failed to read CODEOWNERS file: %w", err)
}
packagePath := fmt.Sprintf("/packages/%s", packageName)
packageTeams, found := owners.owners[packagePath]
if !found {
return nil, fmt.Errorf("no owner found for package %s", packageName)
}
if dataStream == "" {
return packageTeams, nil
}
dataStreamPath := fmt.Sprintf("/packages/%s/data_stream/%s", packageName, dataStream)
dataStreamTeams, found := owners.owners[dataStreamPath]
if !found {
return packageTeams, nil
}
return dataStreamTeams, nil
}
type githubOwners struct {
owners map[string][]string
path string
}
// validatePackages checks if all packages in packagesDir have a manifest.yml file
// with the correct owner as captured in codeowners. Also, for packages that share ownership across
// data_streams, it checks that all data_streams are explicitly owned by a single owner. Such ownership
// sharing packages are identified by having at least one data_stream with explicit ownership in codeowners.
func validatePackages(codeowners *githubOwners, packagesDir string) error {
packageDirEntries, err := os.ReadDir(packagesDir)
if err != nil {
return err
}
if len(packageDirEntries) == 0 {
if len(codeowners.owners) == 0 {
return nil
}
return fmt.Errorf("no packages found in %q", packagesDir)
}
for _, packageDirEntry := range packageDirEntries {
packageName := packageDirEntry.Name()
packagePath := path.Join(packagesDir, packageName)
packageManifestPath := path.Join(packagePath, "manifest.yml")
err = codeowners.checkManifest(packageManifestPath)
if err != nil {
return err
}
err = codeowners.checkDataStreams(packagePath)
if err != nil {
return err
}
}
return nil
}
func readGithubOwners(codeownersPath string) (*githubOwners, error) {
f, err := os.Open(codeownersPath)
if err != nil {
return nil, fmt.Errorf("failed to open %q: %w", codeownersPath, err)
}
defer f.Close()
codeowners := githubOwners{
owners: make(map[string][]string),
path: codeownersPath,
}
scanner := bufio.NewScanner(f)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) == 1 {
err := codeowners.checkSingleField(fields[0])
if err != nil {
return nil, fmt.Errorf("invalid line %d in %q: %w", lineNumber, codeownersPath, err)
}
continue
}
path, owners := fields[0], fields[1:]
// It is ok to overwrite because latter lines have precedence in these files.
codeowners.owners[path] = owners
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanner error: %w", err)
}
return &codeowners, nil
}
// checkSingleField checks if a single field in a CODEOWNERS file is valid.
// We allow single fields to add files for which we don't need to have owners.
func (codeowners *githubOwners) checkSingleField(field string) error {
switch field[0] {
case '/':
// Allow only rules that wouldn't remove owners for previously
// defined rules.
for path := range codeowners.owners {
matches, err := filepath.Match(field, path)
if err != nil {
return err
}
if matches || strings.HasPrefix(field, path) {
return fmt.Errorf("%q would remove owners for %q", field, path)
}
if strings.HasPrefix(path, field) {
_, err := filepath.Rel(field, path)
if err == nil {
return fmt.Errorf("%q would remove owners for %q", field, path)
}
}
}
// Excluding other files is fine.
return nil
case '@':
return fmt.Errorf("rule with owner without path: %q", field)
default:
return fmt.Errorf("unexpected field found: %q", field)
}
}
func (codeowners *githubOwners) checkManifest(path string) error {
pkgDir := filepath.Dir(path)
owners, found := codeowners.owners["/"+pkgDir]
if !found {
return fmt.Errorf("there is no owner for %q in %q", pkgDir, codeowners.path)
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
var manifest struct {
Owner struct {
Github string `yaml:"github"`
} `yaml:"owner"`
}
err = yaml.Unmarshal(content, &manifest)
if err != nil {
return err
}
if manifest.Owner.Github == "" {
return fmt.Errorf("no owner specified in %q", path)
}
found = false
for _, owner := range owners {
if owner == "@"+manifest.Owner.Github {
found = true
break
}
}
if !found {
return fmt.Errorf("owner %q defined in %q is not in %q", manifest.Owner.Github, path, codeowners.path)
}
return nil
}
func (codeowners *githubOwners) checkDataStreams(packagePath string) error {
packageDataStreamsPath := path.Join(packagePath, "data_stream")
if _, err := os.Stat(packageDataStreamsPath); os.IsNotExist(err) {
// package doesn't have data_streams
return nil
}
dataStreamDirEntries, err := os.ReadDir(packageDataStreamsPath)
if err != nil {
return err
}
totalDataStreams := len(dataStreamDirEntries)
if totalDataStreams == 0 {
// package doesn't have data_streams
return nil
}
var dataStreamsWithoutOwner []string
for _, dataStreamDirEntry := range dataStreamDirEntries {
dataStreamName := dataStreamDirEntry.Name()
dataStreamDir := path.Join(packageDataStreamsPath, dataStreamName)
dataStreamOwners, found := codeowners.owners["/"+dataStreamDir]
if !found {
dataStreamsWithoutOwner = append(dataStreamsWithoutOwner, dataStreamDir)
continue
}
if len(dataStreamOwners) > 1 {
return fmt.Errorf("data stream \"%s\" of package \"%s\" has more than one owners [%s]", dataStreamDir,
packagePath, strings.Join(dataStreamOwners, ", "))
}
}
if notFound := len(dataStreamsWithoutOwner); notFound > 0 && notFound != totalDataStreams {
return fmt.Errorf("package \"%s\" shares ownership across data streams but these ones [%s] lack owners", packagePath,
strings.Join(dataStreamsWithoutOwner, ", "))
}
return nil
}