Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse options w/ option for overriding owner matchers #23

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package codeowners_test
import (
"bytes"
"fmt"
"regexp"

"github.com/hmarr/codeowners"
)
Expand Down Expand Up @@ -41,6 +42,43 @@ func ExampleParseFile() {
// Go code
}

func ExampleParseFile_customOwnerMatchers() {
validUsernames := []string{"the-a-team", "the-b-team"}
usernameRegexp := regexp.MustCompile(`\A@([a-zA-Z0-9\-]+)\z`)

f := bytes.NewBufferString("src/**/*.go @the-a-team # Go code")
ownerMatchers := []codeowners.OwnerMatcher{
codeowners.OwnerMatchFunc(codeowners.MatchEmailOwner),
codeowners.OwnerMatchFunc(func(s string) (codeowners.Owner, error) {
// Custom owner matcher that only matches valid usernames
match := usernameRegexp.FindStringSubmatch(s)
if match == nil {
return codeowners.Owner{}, codeowners.ErrNoMatch
}

for _, t := range validUsernames {
if t == match[1] {
return codeowners.Owner{Value: match[1], Type: codeowners.TeamOwner}, nil
}
}
return codeowners.Owner{}, codeowners.ErrNoMatch
}),
}
ruleset, err := codeowners.ParseFile(f, codeowners.WithOwnerMatchers(ownerMatchers))
if err != nil {
panic(err)
}
fmt.Println(len(ruleset))
fmt.Println(ruleset[0].RawPattern())
fmt.Println(ruleset[0].Owners[0].String())
fmt.Println(ruleset[0].Comment)
// Output:
// 1
// src/**/*.go
// @the-a-team
// Go code
}

func ExampleRuleset_Match() {
f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code")
ruleset, _ := codeowners.ParseFile(f)
Expand Down
118 changes: 95 additions & 23 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,94 @@ package codeowners
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"strings"
)

type parseOption func(*parseOptions)

type parseOptions struct {
ownerMatchers []OwnerMatcher
}

func WithOwnerMatchers(mm []OwnerMatcher) parseOption {
return func(opts *parseOptions) {
opts.ownerMatchers = mm
}
}

type OwnerMatcher interface {
// Matches give string agains a pattern e.g. a regexp.
// Should return ErrNoMatch if the pattern doesn't match.
Match(s string) (Owner, error)
}

type ErrInvalidOwnerFormat struct {
Owner string
}

func (err ErrInvalidOwnerFormat) Error() string {
return fmt.Sprintf("invalid owner format '%s'", err.Owner)
}

var ErrNoMatch = errors.New("no match")

var (
emailRegexp = regexp.MustCompile(`\A[A-Z0-9a-z\._%\+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,6}\z`)
teamRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-]+\/[a-zA-Z0-9_\-]+)\z`)
usernameRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-]+)\z`)
)

const (
statePattern = iota + 1
stateOwners
)
var DefaultOwnerMatchers = []OwnerMatcher{
OwnerMatchFunc(MatchEmailOwner),
OwnerMatchFunc(MatchTeamOwner),
OwnerMatchFunc(MatchUsernameOwner),
}

type OwnerMatchFunc func(s string) (Owner, error)

func (f OwnerMatchFunc) Match(s string) (Owner, error) {
return f(s)
}

func MatchEmailOwner(s string) (Owner, error) {
match := emailRegexp.FindStringSubmatch(s)
if match == nil {
return Owner{}, ErrNoMatch
}

return Owner{Value: match[0], Type: EmailOwner}, nil
}

func MatchTeamOwner(s string) (Owner, error) {
match := teamRegexp.FindStringSubmatch(s)
if match == nil {
return Owner{}, ErrNoMatch
}

return Owner{Value: match[1], Type: TeamOwner}, nil
}

func MatchUsernameOwner(s string) (Owner, error) {
match := usernameRegexp.FindStringSubmatch(s)
if match == nil {
return Owner{}, ErrNoMatch
}

return Owner{Value: match[1], Type: UsernameOwner}, nil
}

// ParseFile parses a CODEOWNERS file, returning a set of rules.
func ParseFile(f io.Reader) (Ruleset, error) {
// To override the default owner matchers, pass WithOwnerMatchers() as an option.
func ParseFile(f io.Reader, options ...parseOption) (Ruleset, error) {
opts := parseOptions{ownerMatchers: DefaultOwnerMatchers}
for _, opt := range options {
opt(&opts)
}

rules := Ruleset{}
scanner := bufio.NewScanner(f)
lineNo := 0
Expand All @@ -34,7 +103,7 @@ func ParseFile(f io.Reader) (Ruleset, error) {
continue
}

rule, err := parseRule(line)
rule, err := parseRule(line, opts)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
Expand All @@ -44,8 +113,13 @@ func ParseFile(f io.Reader) (Ruleset, error) {
return rules, nil
}

const (
statePattern = iota + 1
stateOwners
)

// parseRule parses a single line of a CODEOWNERS file, returning a Rule struct
func parseRule(ruleStr string) (Rule, error) {
func parseRule(ruleStr string, opts parseOptions) (Rule, error) {
r := Rule{}

state := statePattern
Expand Down Expand Up @@ -95,9 +169,9 @@ func parseRule(ruleStr string) (Rule, error) {
// through whitespace before or after owner declarations
if buf.Len() > 0 {
ownerStr := buf.String()
owner, err := newOwner(ownerStr)
owner, err := newOwner(ownerStr, opts.ownerMatchers)
if err != nil {
return r, fmt.Errorf("%s at position %d", err.Error(), i+1-len(ownerStr))
return r, fmt.Errorf("%w at position %d", err, i+1-len(ownerStr))
}
r.Owners = append(r.Owners, owner)
buf.Reset()
Expand Down Expand Up @@ -131,7 +205,7 @@ func parseRule(ruleStr string) (Rule, error) {
// If there's an owner left in the buffer, don't leave it behind
if buf.Len() > 0 {
ownerStr := buf.String()
owner, err := newOwner(ownerStr)
owner, err := newOwner(ownerStr, opts.ownerMatchers)
if err != nil {
return r, fmt.Errorf("%s at position %d", err.Error(), len(ruleStr)+1-len(ownerStr))
}
Expand All @@ -143,23 +217,21 @@ func parseRule(ruleStr string) (Rule, error) {
}

// newOwner figures out which kind of owner this is and returns an Owner struct
func newOwner(s string) (Owner, error) {
match := emailRegexp.FindStringSubmatch(s)
if match != nil {
return Owner{Value: match[0], Type: EmailOwner}, nil
}
func newOwner(s string, mm []OwnerMatcher) (Owner, error) {
for _, m := range mm {
o, err := m.Match(s)
if errors.Is(err, ErrNoMatch) {
continue
} else if err != nil {
return Owner{}, err
}

match = teamRegexp.FindStringSubmatch(s)
if match != nil {
return Owner{Value: match[1], Type: TeamOwner}, nil
return o, nil
}

match = usernameRegexp.FindStringSubmatch(s)
if match != nil {
return Owner{Value: match[1], Type: UsernameOwner}, nil
return Owner{}, ErrInvalidOwnerFormat{
Owner: s,
}

return Owner{}, fmt.Errorf("invalid owner format '%s'", s)
}

func isWhitespace(ch rune) bool {
Expand Down
42 changes: 37 additions & 5 deletions parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (

func TestParseRule(t *testing.T) {
examples := []struct {
name string
rule string
expected Rule
err string
name string
rule string
ownerMatchers []OwnerMatcher
expected Rule
err string
}{
// Success cases
{
Expand Down Expand Up @@ -142,11 +143,42 @@ func TestParseRule(t *testing.T) {
rule: "file.txt missing-at-sign",
err: "invalid owner format 'missing-at-sign' at position 10",
},
{
name: "email owners without email matcher",
rule: "file.txt [email protected]",
ownerMatchers: []OwnerMatcher{
OwnerMatchFunc(MatchTeamOwner),
OwnerMatchFunc(MatchUsernameOwner),
},
err: "invalid owner format '[email protected]' at position 10",
},
{
name: "team owners without team matcher",
rule: "file.txt @org/team",
ownerMatchers: []OwnerMatcher{
OwnerMatchFunc(MatchEmailOwner),
OwnerMatchFunc(MatchUsernameOwner),
},
err: "invalid owner format '@org/team' at position 10",
},
{
name: "username owners without username matcher",
rule: "file.txt @user",
ownerMatchers: []OwnerMatcher{
OwnerMatchFunc(MatchEmailOwner),
OwnerMatchFunc(MatchTeamOwner),
},
err: "invalid owner format '@user' at position 10",
},
}

for _, e := range examples {
t.Run("parses "+e.name, func(t *testing.T) {
actual, err := parseRule(e.rule)
opts := parseOptions{ownerMatchers: DefaultOwnerMatchers}
if e.ownerMatchers != nil {
opts.ownerMatchers = e.ownerMatchers
}
actual, err := parseRule(e.rule, opts)
if e.err != "" {
assert.EqualError(t, err, e.err)
} else {
Expand Down