Files
zzet--gortex/internal/codeowners/parser.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

202 lines
6.7 KiB
Go

// Package codeowners parses GitHub-style CODEOWNERS files into a
// rule list, applies last-match-wins matching, and emits team and
// person nodes plus EdgeOwns edges so blast-radius queries can
// surface "who needs to know" alongside the changed files.
//
// CODEOWNERS file syntax follows GitHub's documented format:
// gitignore-style patterns, one rule per non-comment line, owners
// listed after the pattern as @-prefixed handles. The last matching
// rule wins.
package codeowners
import (
"bufio"
"bytes"
"os"
"path/filepath"
"strings"
gitignore "github.com/sabhiram/go-gitignore"
"github.com/zzet/gortex/internal/graph"
)
// Rule is one parsed CODEOWNERS line. Pattern is the gitignore-style
// glob; Owners is the list of @-handles or email addresses.
type Rule struct {
Pattern string
Owners []string
matcher *gitignore.GitIgnore
}
// matchPattern returns the rule's gitignore matcher. Parse precompiles
// it, so for any Parse-built Rule the field is non-nil and MatchFile's
// concurrent hot path only reads it — no data race on a shared rule list
// (applyCoverageDomains matches files across goroutines against one
// list). For a Rule hand-constructed outside Parse the field is nil;
// compile a throwaway matcher rather than caching into r.matcher, so
// concurrent callers still can't race on the field.
func (r *Rule) matchPattern() *gitignore.GitIgnore {
if r.matcher != nil {
return r.matcher
}
return gitignore.CompileIgnoreLines(r.Pattern)
}
// Parse reads a CODEOWNERS file's bytes and returns the rule list in
// document order. Comment lines (#…) and blank lines are skipped.
// Lines without owners are kept as Rules with an empty Owners list —
// they are valid CODEOWNERS syntax (a way to "blank out" an earlier
// rule for matched paths) and downstream consumers can decide
// whether to ignore them.
func Parse(source []byte) []Rule {
if len(source) == 0 {
return nil
}
var rules []Rule
scanner := bufio.NewScanner(bytes.NewReader(source))
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Drop trailing inline comments. CODEOWNERS supports them
// (GitHub treats anything after a "#" the same as gitignore
// does), so honour the comment delimiter even mid-line.
if i := strings.Index(line, " #"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
rule := Rule{Pattern: fields[0]}
// Precompile the matcher in this single-goroutine parse so the
// concurrent MatchFile hot path only reads rule.matcher.
rule.matcher = gitignore.CompileIgnoreLines(rule.Pattern)
if len(fields) > 1 {
rule.Owners = append(rule.Owners, fields[1:]...)
}
rules = append(rules, rule)
}
return rules
}
// MatchFile applies the last-match-wins rule against path and
// returns the matching owner list. Returns nil if no rule matched
// or if the matching rule has no owners. path should be relative
// to the repo root with forward-slash separators.
func MatchFile(path string, rules []Rule) []string {
path = filepath.ToSlash(path)
for i := len(rules) - 1; i >= 0; i-- {
r := &rules[i]
if r.matchPattern().MatchesPath(path) {
if len(r.Owners) == 0 {
return nil
}
out := make([]string, len(r.Owners))
copy(out, r.Owners)
return out
}
}
return nil
}
// LoadFromRepo locates and parses the first CODEOWNERS file found in
// the standard locations relative to repoRoot. Returns nil rules
// and ok=false when no file exists. Locations checked in order:
// .github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS — matching
// GitHub's resolution.
func LoadFromRepo(repoRoot string) (rules []Rule, sourcePath string, ok bool) {
for _, rel := range []string{
".github/CODEOWNERS",
"CODEOWNERS",
"docs/CODEOWNERS",
} {
full := filepath.Join(repoRoot, rel)
data, err := os.ReadFile(full)
if err != nil {
continue
}
return Parse(data), rel, true
}
return nil, "", false
}
// BuildGraphArtifacts produces a team node and EdgeOwns edge for
// each owner in owners. The team node ID convention is `team::<name>`
// (the leading "@" or trailing email-domain is preserved verbatim
// inside the node name and meta — the prefix exists only to keep
// IDs unique against future "person::" or "org::" namespaces).
//
// Team nodes are shared across files in the repo; graph.AddNode is
// idempotent on ID so re-emitting per file is cheap. The Meta.kind
// disambiguates teams from individuals: an owner containing "/" or
// matching "@org/team" is a team; everything else (a bare @user or
// an email) is a person.
//
// filePath is the unprefixed path; applyRepoPrefix downstream
// handles multi-repo namespacing.
func BuildGraphArtifacts(filePath string, owners []string, language string) ([]*graph.Node, []*graph.Edge) {
if len(owners) == 0 {
return nil, nil
}
filePath = filepath.ToSlash(filePath)
nodes := make([]*graph.Node, 0, len(owners))
edges := make([]*graph.Edge, 0, len(owners))
for _, owner := range owners {
owner = strings.TrimSpace(owner)
if owner == "" {
continue
}
nodes = append(nodes, &graph.Node{
ID: TeamNodeID(owner),
Kind: graph.KindTeam,
Name: owner,
FilePath: filePath, // first sighting; not authoritative
Language: language,
Meta: map[string]any{
"owner": owner,
"kind": classifyOwner(owner),
},
})
edges = append(edges, &graph.Edge{
From: TeamNodeID(owner),
To: filePath,
Kind: graph.EdgeOwns,
FilePath: filePath,
Origin: graph.OriginASTResolved,
})
}
return nodes, edges
}
// TeamNodeID returns the canonical ID for an owner node. We strip
// the leading "@" so `@core` and `core` produce the same ID — the
// "@" is purely CODEOWNERS syntax, not part of the team identity.
// Repo-scoped via applyRepoPrefix in multi-repo mode (same as
// annotation:: nodes — see scanner.go in internal/licenses for
// notes on cross-repo de-dup as a v2 concern).
func TeamNodeID(owner string) string {
owner = strings.TrimPrefix(strings.TrimSpace(owner), "@")
return "team::" + owner
}
// classifyOwner returns "team" or "person". GitHub teams take the
// form "@org/team" (one slash); GitLab uses "@group/subgroup/team".
// Either form contains a slash; bare @users do not. Email addresses
// are people. Everything else defaults to person — false positives
// are recoverable by the user via .gortex.yaml override (out of
// scope for v1).
func classifyOwner(owner string) string {
owner = strings.TrimPrefix(owner, "@")
switch {
case strings.Contains(owner, "/"):
return "team"
case strings.Contains(owner, "@"):
return "person"
default:
return "person"
}
}