chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
// 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"
}
}
+34
View File
@@ -0,0 +1,34 @@
package codeowners_test
import (
"sync"
"testing"
"github.com/zzet/gortex/internal/codeowners"
)
// TestMatchFile_ConcurrentNoRace exercises MatchFile from many goroutines over
// a single shared rule list — the way the indexer's per-file coverage
// goroutines (applyCoverageDomains) call it. Pre-fix, matchPattern lazily
// compiled and cached r.matcher without synchronisation, so concurrent first
// calls raced on the shared *Rule (and on the half-published GitIgnore). Run
// under -race; it must be clean.
func TestMatchFile_ConcurrentNoRace(t *testing.T) {
rules := codeowners.Parse([]byte(
"*.go @gophers\n" +
"/docs/ @writers\n" +
"src/**/*.ts @frontend @core\n" +
"*.md @docs\n",
))
paths := []string{"main.go", "docs/readme.md", "src/a/b/c.ts", "x/y/z.py", "pkg/foo.go", "README.md"}
var wg sync.WaitGroup
for range 64 {
wg.Go(func() {
for i := range 200 {
_ = codeowners.MatchFile(paths[i%len(paths)], rules)
}
})
}
wg.Wait()
}
+112
View File
@@ -0,0 +1,112 @@
package codeowners
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/graph"
)
func TestParse_BasicRules(t *testing.T) {
src := []byte(`# global ownership
* @everyone
*.go @go-team
/docs/ @docs-team @alice
internal/auth/ @org/security
`)
rules := Parse(src)
if len(rules) != 4 {
t.Fatalf("expected 4 rules, got %d", len(rules))
}
if rules[0].Pattern != "*" || rules[0].Owners[0] != "@everyone" {
t.Errorf("rule 0 wrong: %+v", rules[0])
}
if len(rules[2].Owners) != 2 {
t.Errorf("rule 2 owners = %v", rules[2].Owners)
}
}
func TestParse_StripsInlineComments(t *testing.T) {
src := []byte("*.go @go-team # owners of all go files\n")
rules := Parse(src)
if len(rules) != 1 || rules[0].Owners[0] != "@go-team" {
t.Errorf("got %+v", rules)
}
}
func TestMatchFile_LastMatchWins(t *testing.T) {
rules := Parse([]byte(`* @everyone
*.go @go-team
internal/auth/ @security
`))
cases := []struct {
path string
want []string
}{
{"README.md", []string{"@everyone"}},
{"main.go", []string{"@go-team"}},
{"internal/auth/jwt.go", []string{"@security"}},
}
for _, tc := range cases {
got := MatchFile(tc.path, rules)
if len(got) != len(tc.want) || (len(got) > 0 && got[0] != tc.want[0]) {
t.Errorf("path %q: got %v, want %v", tc.path, got, tc.want)
}
}
}
func TestLoadFromRepo(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, ".github")
if err := os.Mkdir(subdir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(subdir, "CODEOWNERS")
if err := os.WriteFile(path, []byte("* @everyone\n"), 0o644); err != nil {
t.Fatal(err)
}
rules, src, ok := LoadFromRepo(dir)
if !ok {
t.Fatalf("expected to find CODEOWNERS")
}
if src != ".github/CODEOWNERS" {
t.Errorf("source path = %q", src)
}
if len(rules) != 1 {
t.Errorf("rules = %d", len(rules))
}
}
func TestLoadFromRepo_NoFile(t *testing.T) {
dir := t.TempDir()
_, _, ok := LoadFromRepo(dir)
if ok {
t.Errorf("expected ok=false for empty dir")
}
}
func TestBuildGraphArtifacts(t *testing.T) {
nodes, edges := BuildGraphArtifacts("pkg/foo.go", []string{"@org/security", "@alice"}, "go")
if len(nodes) != 2 {
t.Fatalf("nodes = %d", len(nodes))
}
if nodes[0].ID != "team::org/security" {
t.Errorf("team id = %q", nodes[0].ID)
}
if nodes[0].Meta["kind"] != "team" {
t.Errorf("kind = %v", nodes[0].Meta["kind"])
}
if nodes[1].ID != "team::alice" {
t.Errorf("person id = %q", nodes[1].ID)
}
if nodes[1].Meta["kind"] != "person" {
t.Errorf("kind = %v", nodes[1].Meta["kind"])
}
if edges[0].Kind != graph.EdgeOwns {
t.Errorf("edge kind = %q", edges[0].Kind)
}
if edges[0].From != "team::org/security" || edges[0].To != "pkg/foo.go" {
t.Errorf("edge endpoints wrong: %s -> %s", edges[0].From, edges[0].To)
}
}