From 02f3ee8dcc8e2a66ba1b0ba73823dde8522f2915 Mon Sep 17 00:00:00 2001 From: Engin Manap Date: Tue, 8 Sep 2026 11:17:33 +0200 Subject: [PATCH] Add GitLab CODEOWNERS section support Parse section headers ([Name], [Name][N], ^[Name], ^[Name][N]) and attach section metadata (name, min-approvals threshold, optional flag) to each Rule via a new Section *Section field. Rules that appear before any section header have Section == nil, preserving existing behaviour. Introduces: - Section type in codeowners.go - parseSectionHeader helper + currentSection tracking in ParseFile - TestParseSectionHeader (unit) and TestParseFile section cases - ExampleParseFile_sections runnable example --- codeowners.go | 18 +++- example_test.go | 20 ++++ parse.go | 39 ++++++++ parse_test.go | 240 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) diff --git a/codeowners.go b/codeowners.go index 1a7dda4..b24076b 100644 --- a/codeowners.go +++ b/codeowners.go @@ -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. diff --git a/example_test.go b/example_test.go index dd3259d..2e8b018 100644 --- a/example_test.go +++ b/example_test.go @@ -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) diff --git a/parse.go b/parse.go index a77feaa..edfb986 100644 --- a/parse.go +++ b/parse.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "regexp" + "strconv" "strings" ) @@ -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()) @@ -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 @@ -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 +} diff --git a/parse_test.go b/parse_test.go index 0e2b833..d949f05 100644 --- a/parse_test.go +++ b/parse_test.go @@ -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 { @@ -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