Skip to content
Open
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
18 changes: 17 additions & 1 deletion codeowners.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,29 @@ func (r Ruleset) Match(path string) (*Rule, error) {
return nil, nil
}

// Section represents a GitLab CODEOWNERS section header. Sections group rules
// and can specify an approval threshold and whether the section is optional.
type Section struct {
// Name is the section identifier from the header, e.g. "Backend".
Name string
// MinApprovals is the minimum number of approvals required for rules in this
// section. If unset in the file, it defaults to 1.
MinApprovals int
// Optional indicates that the section is advisory only and should not block
// merges. An optional section header is prefixed with ^.
Optional bool
}

// Rule is a CODEOWNERS rule that maps a gitignore-style path pattern to a set
// of owners.
type Rule struct {
Owners []Owner
Comment string
LineNumber int
pattern pattern
// Section is the GitLab CODEOWNERS section this rule belongs to, or nil if
// the rule appears before any section header.
Section *Section
pattern pattern
}

// RawPattern returns the rule's gitignore-style path pattern.
Expand Down
20 changes: 20 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ func ExampleParseFile_customOwnerMatchers() {
// Go code
}

func ExampleParseFile_sections() {
contents := "[Backend]\nbackend/** @acme/backend\n\n^[Docs][2]\ndocs/** @acme/docs-team"
f := bytes.NewBufferString(contents)
ruleset, err := codeowners.ParseFile(f)
if err != nil {
panic(err)
}
for _, rule := range ruleset {
fmt.Printf("%s section=%s min=%d optional=%v\n",
rule.RawPattern(),
rule.Section.Name,
rule.Section.MinApprovals,
rule.Section.Optional,
)
}
// Output:
// backend/** section=Backend min=1 optional=false
// docs/** section=Docs min=2 optional=true
}

func ExampleRuleset_Match() {
f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code")
ruleset, _ := codeowners.ParseFile(f)
Expand Down
39 changes: 39 additions & 0 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -106,6 +107,7 @@ func ParseFile(f io.Reader, options ...parseOption) (Ruleset, error) {
rules := Ruleset{}
scanner := bufio.NewScanner(f)
lineNo := 0
var currentSection *Section
for scanner.Scan() {
lineNo++
line := strings.TrimSpace(scanner.Text())
Expand All @@ -115,11 +117,17 @@ func ParseFile(f io.Reader, options ...parseOption) (Ruleset, error) {
continue
}

if section, ok := parseSectionHeader(line); ok {
currentSection = section
continue
}

rule, err := parseRule(line, opts)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNo, err)
}
rule.LineNumber = lineNo
rule.Section = currentSection
rules = append(rules, rule)
}
return rules, nil
Expand Down Expand Up @@ -271,3 +279,34 @@ func isOwnersChar(ch rune) bool {
}
return isAlphanumeric(ch)
}

// sectionHeaderRegexp matches GitLab CODEOWNERS section headers of the form
// [Name], [Name][N], ^[Name], or ^[Name][N].
var sectionHeaderRegexp = regexp.MustCompile(`^(\^?)\[([^\]]+)\](?:\[(\d+)\])?\s*$`)

// parseSectionHeader parses a GitLab CODEOWNERS section header line, returning
// a Section and true if the line is a valid header, or nil and false otherwise.
func parseSectionHeader(line string) (*Section, bool) {
match := sectionHeaderRegexp.FindStringSubmatch(line)
if match == nil {
return nil, false
}

name := strings.TrimSpace(match[2])
if name == "" {
return nil, false
}

s := &Section{
Name: name,
MinApprovals: 1,
Optional: match[1] == "^",
}
if match[3] != "" {
n, err := strconv.Atoi(match[3])
if err == nil {
s.MinApprovals = n
}
}
return s, true
}
240 changes: 240 additions & 0 deletions parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,180 @@ func TestParseFile(t *testing.T) {
},
},

// Section header cases
{
name: "section with default threshold",
contents: "[Backend]\nbackend/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "backend/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "section with explicit threshold",
contents: "[Backend][2]\nbackend/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "backend/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Backend", MinApprovals: 2},
},
},
},
{
name: "optional section",
contents: "^[Docs]\ndocs/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "docs/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Docs", MinApprovals: 1, Optional: true},
},
},
},
{
name: "optional section with explicit threshold",
contents: "^[Docs][3]\ndocs/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "docs/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Docs", MinApprovals: 3, Optional: true},
},
},
},
{
name: "section with zero threshold",
contents: "[Section][0]\nsrc/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "src/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Section", MinApprovals: 0},
},
},
},
{
name: "section name with spaces",
contents: "[My Backend Team]\nbackend/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "backend/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "My Backend Team", MinApprovals: 1},
},
},
},
{
name: "section header followed by blank line",
contents: "[Backend]\n\nbackend/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "backend/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 3,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "two section headers reset section context",
contents: "[Frontend]\nfe/** @user\n[Backend]\nbe/** @user2",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "fe/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Frontend", MinApprovals: 1},
},
{
pattern: mustBuildPattern(t, "be/**"),
Owners: []Owner{{Value: "user2", Type: "username"}},
LineNumber: 4,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "section header with no rules below it",
contents: "[Empty]\n[Backend]\nbe/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "be/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 3,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "rules before section header have nil section",
contents: "* @user\n[Backend]\nbe/** @user2",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "*"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 1,
},
{
pattern: mustBuildPattern(t, "be/**"),
Owners: []Owner{{Value: "user2", Type: "username"}},
LineNumber: 3,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "comment inside section does not change section context",
contents: "[Backend]\n# comment\nbe/** @user",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "be/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 3,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},
{
name: "multiple rules under one section",
contents: "[Backend]\nbe/** @user\napi/** @user2",
expected: Ruleset{
{
pattern: mustBuildPattern(t, "be/**"),
Owners: []Owner{{Value: "user", Type: "username"}},
LineNumber: 2,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
{
pattern: mustBuildPattern(t, "api/**"),
Owners: []Owner{{Value: "user2", Type: "username"}},
LineNumber: 3,
Section: &Section{Name: "Backend", MinApprovals: 1},
},
},
},

// Error cases
{
name: "malformed rule",
contents: "malformed rule\n",
err: "line 1: invalid owner format 'rule' at position 11",
},
{
name: "whitespace-only section name is not a section header",
contents: "[ ]\nbe/** @user",
err: "line 1: unexpected character ']' at position 5",
},
}

for _, e := range examples {
Expand All @@ -86,6 +254,78 @@ func TestParseFile(t *testing.T) {
}
}

func TestParseSectionHeader(t *testing.T) {
tests := []struct {
name string
line string
expected *Section
}{
// Valid section headers
{
name: "required section",
line: "[Backend]",
expected: &Section{Name: "Backend", MinApprovals: 1},
},
{
name: "required section with explicit threshold",
line: "[Backend][2]",
expected: &Section{Name: "Backend", MinApprovals: 2},
},
{
name: "optional section",
line: "^[Docs]",
expected: &Section{Name: "Docs", MinApprovals: 1, Optional: true},
},
{
name: "optional section with explicit threshold",
line: "^[Docs][3]",
expected: &Section{Name: "Docs", MinApprovals: 3, Optional: true},
},
{
name: "zero threshold",
line: "[Section][0]",
expected: &Section{Name: "Section", MinApprovals: 0},
},
{
name: "section name with spaces",
line: "[My Backend Team]",
expected: &Section{Name: "My Backend Team", MinApprovals: 1},
},
{
name: "mixed case name is preserved as-is",
line: "[BackEnd]",
expected: &Section{Name: "BackEnd", MinApprovals: 1},
},
{
name: "name trimmed of surrounding whitespace",
line: "[ Backend ]",
expected: &Section{Name: "Backend", MinApprovals: 1},
},

// Lines that are not section headers
{name: "no closing bracket", line: "[foo", expected: nil},
{name: "empty brackets", line: "[]", expected: nil},
{name: "whitespace-only name", line: "[ ]", expected: nil},
{name: "caret alone", line: "^", expected: nil},
{name: "regular file rule", line: "/backend/** @user", expected: nil},
{name: "comment line", line: "# comment", expected: nil},
{name: "empty line", line: "", expected: nil},
}

for _, test := range tests {
t.Run("parses "+test.name, func(t *testing.T) {
section, ok := parseSectionHeader(test.line)
if test.expected == nil {
assert.False(t, ok)
assert.Nil(t, section)
} else {
assert.True(t, ok)
assert.Equal(t, test.expected, section)
}
})
}
}

func TestParseRule(t *testing.T) {
examples := []struct {
name string
Expand Down