From 3b8c9daed6207b9fb4e455881e905b6780cd6a28 Mon Sep 17 00:00:00 2001 From: zbedforrest Date: Wed, 16 Jul 2025 16:42:32 -0700 Subject: [PATCH 1/4] added new command and tests --- tools/cli/main.go | 123 +++++++++++++++++++++++++++++++++++++++++ tools/cli/main_test.go | 104 ++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/tools/cli/main.go b/tools/cli/main.go index 25f8d0b..a065101 100644 --- a/tools/cli/main.go +++ b/tools/cli/main.go @@ -182,6 +182,33 @@ func main() { return nil }, }, + { + Name: "map", + Aliases: []string{"m"}, + Usage: "Generate a JSON ownership map of the entire repository", + UsageText: "codeowners-cli map [options]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "root", + Aliases: []string{"r", "repo"}, + Value: "./", + Usage: "Path to local Git repo", + Destination: &repo, + }, + &cli.StringFlag{ + Name: "by", + Value: "file", + Usage: "Map by 'file' (files to owners) or 'owner' (owners to files)", + }, + }, + Action: func(cCtx *cli.Context) error { + mapBy := cCtx.String("by") + if mapBy != "file" && mapBy != "owner" { + return fmt.Errorf("invalid value for --by flag: must be 'file' or 'owner'") + } + return generateOwnershipMap(repo, mapBy) + }, + }, }, } @@ -408,6 +435,102 @@ func fileOwner(repo string, targets []string, format OutputFormat) error { return nil } +// generateOwnershipMap walks the entire repository, determines file ownership, +// and prints a comprehensive JSON map of the results. +func generateOwnershipMap(repo string, mapBy string) error { + if repoStat, err := os.Lstat(repo); err != nil || !repoStat.IsDir() { + return fmt.Errorf("root is not a directory: %s", repo) + } + if gitStat, err := os.Stat(filepath.Join(repo, ".git")); err != nil || !gitStat.IsDir() { + return fmt.Errorf("root is not a Git repository: %s", repo) + } + + fileListQueue := make(chan *gocodewalker.File, 100) + walker := gocodewalker.NewFileWalker(repo, fileListQueue) + walker.IncludeHidden = true + walker.ExcludeDirectory = []string{".git"} + errChan := make(chan error) + go func() { + err := walker.Start() + errChan <- err + close(errChan) + }() + + files := make([]codeowners.DiffFile, 0) + for f := range fileListQueue { + file := stripRoot(repo, f.Location) + files = append(files, codeowners.DiffFile{FileName: file}) + } + + if err := <-errChan; err != nil { + return fmt.Errorf("error walking repo: %s", err) + } + + ownersMap, err := codeowners.New(repo, files, io.Discard) + if err != nil { + return fmt.Errorf("error reading codeowners config: %s", err) + } + + var result interface{} + if mapBy == "file" { + result = mapFilesToOwners(ownersMap) + } else { + result = mapOwnersToFiles(ownersMap) + } + + jsonData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("error marshaling JSON: %s", err) + } + + fmt.Println(string(jsonData)) + return nil +} + +// mapFilesToOwners creates a map where keys are file paths and values are a +// slice of the owners for that file. +func mapFilesToOwners(ownersMap codeowners.CodeOwners) map[string][]string { + fileToOwners := make(map[string][]string) + allFileOwners := ownersMap.FileRequired() + + for file, reviewerGroups := range ownersMap.FileOptional() { + if _, ok := allFileOwners[file]; !ok { + allFileOwners[file] = reviewerGroups + } else { + allFileOwners[file] = append(allFileOwners[file], reviewerGroups...) + } + } + + for file, reviewerGroups := range allFileOwners { + fileToOwners[file] = f.RemoveDuplicates(reviewerGroups.Flatten()) + slices.Sort(fileToOwners[file]) + } + return fileToOwners +} + +// mapOwnersToFiles creates a map where keys are owner names and values are a +// slice of the file paths that owner is responsible for. +func mapOwnersToFiles(ownersMap codeowners.CodeOwners) map[string][]string { + ownerToFiles := make(map[string][]string) + for file, reviewerGroups := range ownersMap.FileRequired() { + for _, owner := range reviewerGroups.Flatten() { + ownerToFiles[owner] = append(ownerToFiles[owner], file) + } + } + for file, reviewerGroups := range ownersMap.FileOptional() { + for _, owner := range reviewerGroups.Flatten() { + ownerToFiles[owner] = append(ownerToFiles[owner], file) + } + } + + for owner, files := range ownerToFiles { + dedupedFiles := f.RemoveDuplicates(files) + slices.Sort(dedupedFiles) + ownerToFiles[owner] = dedupedFiles + } + return ownerToFiles +} + func validateCodeowners(repo string, target string) error { if repoStat, err := os.Lstat(repo); err != nil || !repoStat.IsDir() { return fmt.Errorf("root is not a directory: %s", repo) diff --git a/tools/cli/main_test.go b/tools/cli/main_test.go index cdb9731..d5b07a1 100644 --- a/tools/cli/main_test.go +++ b/tools/cli/main_test.go @@ -639,3 +639,107 @@ func TestUnownedFilesWithFormat(t *testing.T) { }) } } + +func TestGenerateOwnershipMap(t *testing.T) { + testRepo, cleanup := setupTestRepo(t) + defer cleanup() + + t.Run("by file", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := generateOwnershipMap(testRepo, "file") + if err != nil { + t.Fatalf("generateOwnershipMap() error = %v", err) + } + + _ = w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + var got map[string][]string + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal json: %v", err) + } + + want := map[string][]string{ + ".codeowners": {"@default-owner"}, + "main.go": {"@backend-team"}, + "internal/.codeowners": {"@default-owner", "@security-team"}, + "internal/util.go": {"@backend-team", "@security-team"}, + "frontend/.codeowners": {"@default-owner"}, + "frontend/app.js": {"@frontend-team"}, + "frontend/app.ts": {"@frontend-team"}, + "tests/.codeowners": {"@default-owner"}, + "tests/some.test.js": {"@frontend-team", "@qa-team"}, + "tests/some.test.go": {"@backend-team"}, + } + + if len(got) != len(want) { + t.Errorf("map by file: got %d files, want %d", len(got), len(want)) + } + + for file, wantOwners := range want { + gotOwners, ok := got[file] + if !ok { + t.Errorf("map by file: missing file %s in output", file) + continue + } + if !f.SlicesItemsMatch(gotOwners, wantOwners) { + t.Errorf("map by file: for file %s, got %v, want %v", file, gotOwners, wantOwners) + } + } + }) + + t.Run("by owner", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := generateOwnershipMap(testRepo, "owner") + if err != nil { + t.Fatalf("generateOwnershipMap() error = %v", err) + } + + _ = w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + var got map[string][]string + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("failed to unmarshal json: %v", err) + } + + want := map[string][]string{ + "@default-owner": {".codeowners", "frontend/.codeowners", "internal/.codeowners", "tests/.codeowners"}, + "@backend-team": {"internal/util.go", "main.go", "tests/some.test.go"}, + "@security-team": {"internal/util.go"}, + "@frontend-team": {"frontend/app.js", "frontend/app.ts", "tests/some.test.js"}, + "@qa-team": {"tests/some.test.js"}, + } + + if len(got) != len(want) { + t.Errorf("map by owner: got %d owners, want %d", len(got), len(want)) + } + + for owner, wantFiles := range want { + gotFiles, ok := got[owner] + if !ok { + t.Errorf("map by owner: missing owner %s in output", owner) + continue + } + // Use a map for efficient lookup + gotFilesSet := make(map[string]struct{}, len(gotFiles)) + for _, file := range gotFiles { + gotFilesSet[file] = struct{}{} + } + + for _, wantFile := range wantFiles { + if _, ok := gotFilesSet[wantFile]; !ok { + t.Errorf("map by owner: for owner %s, missing expected file %s in got %v", owner, wantFile, gotFiles) + } + } + } + }) +} From ddeaf6fd0e1e64ea8a67e8f9155b0ad1452d28a4 Mon Sep 17 00:00:00 2001 From: zbedforrest Date: Wed, 16 Jul 2025 16:58:51 -0700 Subject: [PATCH 2/4] update covbadge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 685a4ec..5b1c7eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better [![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1) [![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml) -![Coverage](https://img.shields.io/badge/Coverage-81.7%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-81.8%25-brightgreen) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) From 57d5ebc925e0efa2bab48cd711c407598d281a16 Mon Sep 17 00:00:00 2001 From: zbedforrest Date: Wed, 16 Jul 2025 17:06:14 -0700 Subject: [PATCH 3/4] reduce duplicated code, update tests, and covbadge --- README.md | 2 +- tools/cli/main.go | 124 +++++++++++++++++++++-------------------- tools/cli/main_test.go | 49 ++++++++++++---- 3 files changed, 104 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 5b1c7eb..685a4ec 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better [![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1) [![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml) -![Coverage](https://img.shields.io/badge/Coverage-81.8%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-81.7%25-brightgreen) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) diff --git a/tools/cli/main.go b/tools/cli/main.go index a065101..193794d 100644 --- a/tools/cli/main.go +++ b/tools/cli/main.go @@ -219,6 +219,33 @@ func main() { } } +// walkRepoFiles walks the git repository and returns a slice of files. +func walkRepoFiles(repo string) ([]codeowners.DiffFile, error) { + fileListQueue := make(chan *gocodewalker.File, 100) + walker := gocodewalker.NewFileWalker(repo, fileListQueue) + walker.IncludeHidden = true + walker.ExcludeDirectory = []string{".git"} + + errChan := make(chan error) + go func() { + err := walker.Start() + errChan <- err + close(errChan) + }() + + files := make([]codeowners.DiffFile, 0) + for f := range fileListQueue { + file := stripRoot(repo, f.Location) + files = append(files, codeowners.DiffFile{FileName: file}) + } + + if err := <-errChan; err != nil { + return nil, fmt.Errorf("error walking repo: %s", err) + } + + return files, nil +} + func depthCheck(path string, target string, depth int) bool { extra := 0 if target != "" { @@ -240,40 +267,27 @@ func unownedFilesWithFormat(repo string, targets []string, depth int, dirsOnly b targets = []string{""} } + allRepoFiles, err := walkRepoFiles(repo) + if err != nil { + return err + } + // Process each target results := make(map[string][]string) for _, target := range targets { - fileListQueue := make(chan *gocodewalker.File, 100) - - walker := gocodewalker.NewFileWalker(repo, fileListQueue) - walker.IncludeHidden = true - walker.ExcludeDirectory = []string{".git"} - - errChan := make(chan error) - - go func() { - err := walker.Start() - errChan <- err - close(errChan) - }() - - files := make([]codeowners.DiffFile, 0) - for f := range fileListQueue { - file := stripRoot(repo, f.Location) + filesForTarget := make([]codeowners.DiffFile, 0) + for _, f := range allRepoFiles { + file := f.FileName if depth != 0 && depthCheck(file, target, depth) { continue } if target != "" && !strings.HasPrefix(file, fmt.Sprintf("%s/", target)) { continue } - files = append(files, codeowners.DiffFile{FileName: file}) - } - - if err := <-errChan; err != nil { - return fmt.Errorf("error walking repo: %s", err) + filesForTarget = append(filesForTarget, f) } - ownersMap, err := codeowners.New(repo, files, io.Discard) + ownersMap, err := codeowners.New(repo, filesForTarget, io.Discard) if err != nil { return fmt.Errorf("error reading codeowners config: %s", err) } @@ -445,25 +459,9 @@ func generateOwnershipMap(repo string, mapBy string) error { return fmt.Errorf("root is not a Git repository: %s", repo) } - fileListQueue := make(chan *gocodewalker.File, 100) - walker := gocodewalker.NewFileWalker(repo, fileListQueue) - walker.IncludeHidden = true - walker.ExcludeDirectory = []string{".git"} - errChan := make(chan error) - go func() { - err := walker.Start() - errChan <- err - close(errChan) - }() - - files := make([]codeowners.DiffFile, 0) - for f := range fileListQueue { - file := stripRoot(repo, f.Location) - files = append(files, codeowners.DiffFile{FileName: file}) - } - - if err := <-errChan; err != nil { - return fmt.Errorf("error walking repo: %s", err) + files, err := walkRepoFiles(repo) + if err != nil { + return err } ownersMap, err := codeowners.New(repo, files, io.Discard) @@ -487,23 +485,34 @@ func generateOwnershipMap(repo string, mapBy string) error { return nil } +// getAllFileOwners consolidates required and optional owners into a single map. +// This helper avoids side effects by working on a copy of the data. +func getAllFileOwners(ownersMap codeowners.CodeOwners) map[string]codeowners.ReviewerGroups { + // Create a new map to avoid side effects on the ownersMap object. + allFileOwners := make(map[string]codeowners.ReviewerGroups) + + for file, reviewerGroups := range ownersMap.FileRequired() { + allFileOwners[file] = reviewerGroups + } + for file, reviewerGroups := range ownersMap.FileOptional() { + allFileOwners[file] = append(allFileOwners[file], reviewerGroups...) + } + return allFileOwners +} + // mapFilesToOwners creates a map where keys are file paths and values are a // slice of the owners for that file. func mapFilesToOwners(ownersMap codeowners.CodeOwners) map[string][]string { + allFileOwners := getAllFileOwners(ownersMap) fileToOwners := make(map[string][]string) - allFileOwners := ownersMap.FileRequired() - - for file, reviewerGroups := range ownersMap.FileOptional() { - if _, ok := allFileOwners[file]; !ok { - allFileOwners[file] = reviewerGroups - } else { - allFileOwners[file] = append(allFileOwners[file], reviewerGroups...) - } - } for file, reviewerGroups := range allFileOwners { - fileToOwners[file] = f.RemoveDuplicates(reviewerGroups.Flatten()) - slices.Sort(fileToOwners[file]) + // Flatten, de-duplicate, and sort the owners. + owners := f.RemoveDuplicates(reviewerGroups.Flatten()) + if len(owners) > 0 { + slices.Sort(owners) + fileToOwners[file] = owners + } } return fileToOwners } @@ -511,13 +520,10 @@ func mapFilesToOwners(ownersMap codeowners.CodeOwners) map[string][]string { // mapOwnersToFiles creates a map where keys are owner names and values are a // slice of the file paths that owner is responsible for. func mapOwnersToFiles(ownersMap codeowners.CodeOwners) map[string][]string { + allFileOwners := getAllFileOwners(ownersMap) ownerToFiles := make(map[string][]string) - for file, reviewerGroups := range ownersMap.FileRequired() { - for _, owner := range reviewerGroups.Flatten() { - ownerToFiles[owner] = append(ownerToFiles[owner], file) - } - } - for file, reviewerGroups := range ownersMap.FileOptional() { + + for file, reviewerGroups := range allFileOwners { for _, owner := range reviewerGroups.Flatten() { ownerToFiles[owner] = append(ownerToFiles[owner], file) } diff --git a/tools/cli/main_test.go b/tools/cli/main_test.go index d5b07a1..7b1fc78 100644 --- a/tools/cli/main_test.go +++ b/tools/cli/main_test.go @@ -714,7 +714,7 @@ func TestGenerateOwnershipMap(t *testing.T) { want := map[string][]string{ "@default-owner": {".codeowners", "frontend/.codeowners", "internal/.codeowners", "tests/.codeowners"}, "@backend-team": {"internal/util.go", "main.go", "tests/some.test.go"}, - "@security-team": {"internal/util.go"}, + "@security-team": {"internal/.codeowners", "internal/util.go"}, "@frontend-team": {"frontend/app.js", "frontend/app.ts", "tests/some.test.js"}, "@qa-team": {"tests/some.test.js"}, } @@ -729,17 +729,44 @@ func TestGenerateOwnershipMap(t *testing.T) { t.Errorf("map by owner: missing owner %s in output", owner) continue } - // Use a map for efficient lookup - gotFilesSet := make(map[string]struct{}, len(gotFiles)) - for _, file := range gotFiles { - gotFilesSet[file] = struct{}{} - } - - for _, wantFile := range wantFiles { - if _, ok := gotFilesSet[wantFile]; !ok { - t.Errorf("map by owner: for owner %s, missing expected file %s in got %v", owner, wantFile, gotFiles) - } + if !f.SlicesItemsMatch(gotFiles, wantFiles) { + t.Errorf("map by owner: for owner %s, got %v, want %v", owner, gotFiles, wantFiles) } } }) } + +func TestWalkRepoFiles(t *testing.T) { + testRepo, cleanup := setupTestRepo(t) + defer cleanup() + + files, err := walkRepoFiles(testRepo) + if err != nil { + t.Fatalf("walkRepoFiles() error = %v", err) + } + + // Convert to a slice of strings for easier comparison + gotFiles := f.Map(files, func(f codeowners.DiffFile) string { + return f.FileName + }) + + wantFiles := []string{ + ".codeowners", + "main.go", + "internal/.codeowners", + "internal/util.go", + "frontend/.codeowners", + "frontend/app.js", + "frontend/app.ts", + "unowned/file.txt", + "unowned/inner/file2.txt", + "unowned2/file3.txt", + "tests/.codeowners", + "tests/some.test.js", + "tests/some.test.go", + } + + if !f.SlicesItemsMatch(gotFiles, wantFiles) { + t.Errorf("walkRepoFiles() got %v, want %v", gotFiles, wantFiles) + } +} From d35bf6c4e2e276a5037397621fc126cf8b6b7fd8 Mon Sep 17 00:00:00 2001 From: zbedforrest Date: Wed, 16 Jul 2025 17:47:28 -0700 Subject: [PATCH 4/4] bump up the cov --- README.md | 2 +- tools/cli/main.go | 25 ++++++---- tools/cli/main_test.go | 102 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 685a4ec..5b1c7eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better [![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1) [![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml) -![Coverage](https://img.shields.io/badge/Coverage-81.7%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-81.8%25-brightgreen) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) diff --git a/tools/cli/main.go b/tools/cli/main.go index 193794d..56c1c53 100644 --- a/tools/cli/main.go +++ b/tools/cli/main.go @@ -276,15 +276,15 @@ func unownedFilesWithFormat(repo string, targets []string, depth int, dirsOnly b results := make(map[string][]string) for _, target := range targets { filesForTarget := make([]codeowners.DiffFile, 0) - for _, f := range allRepoFiles { - file := f.FileName + for _, repoFile := range allRepoFiles { + file := repoFile.FileName if depth != 0 && depthCheck(file, target, depth) { continue } if target != "" && !strings.HasPrefix(file, fmt.Sprintf("%s/", target)) { continue } - filesForTarget = append(filesForTarget, f) + filesForTarget = append(filesForTarget, repoFile) } ownersMap, err := codeowners.New(repo, filesForTarget, io.Discard) @@ -521,18 +521,25 @@ func mapFilesToOwners(ownersMap codeowners.CodeOwners) map[string][]string { // slice of the file paths that owner is responsible for. func mapOwnersToFiles(ownersMap codeowners.CodeOwners) map[string][]string { allFileOwners := getAllFileOwners(ownersMap) - ownerToFiles := make(map[string][]string) + ownerToFilesSet := make(map[string]map[string]struct{}) for file, reviewerGroups := range allFileOwners { for _, owner := range reviewerGroups.Flatten() { - ownerToFiles[owner] = append(ownerToFiles[owner], file) + if _, ok := ownerToFilesSet[owner]; !ok { + ownerToFilesSet[owner] = make(map[string]struct{}) + } + ownerToFilesSet[owner][file] = struct{}{} } } - for owner, files := range ownerToFiles { - dedupedFiles := f.RemoveDuplicates(files) - slices.Sort(dedupedFiles) - ownerToFiles[owner] = dedupedFiles + ownerToFiles := make(map[string][]string) + for owner, filesSet := range ownerToFilesSet { + files := make([]string, 0, len(filesSet)) + for file := range filesSet { + files = append(files, file) + } + slices.Sort(files) + ownerToFiles[owner] = files } return ownerToFiles } diff --git a/tools/cli/main_test.go b/tools/cli/main_test.go index 7b1fc78..30706ce 100644 --- a/tools/cli/main_test.go +++ b/tools/cli/main_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "testing" @@ -736,6 +737,107 @@ func TestGenerateOwnershipMap(t *testing.T) { }) } +func TestMapOwnersToFiles(t *testing.T) { + tests := []struct { + name string + input codeowners.CodeOwners + expected map[string][]string + }{ + { + name: "no owners", + input: &fakeCodeOwners{}, + expected: map[string][]string{}, + }, + { + name: "simple case", + input: &fakeCodeOwners{ + required: map[string]codeowners.ReviewerGroups{ + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + "b.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner2"}}}, + }, + }, + expected: map[string][]string{ + "@owner1": {"a.txt"}, + "@owner2": {"b.txt"}, + }, + }, + { + name: "owner with multiple files", + input: &fakeCodeOwners{ + required: map[string]codeowners.ReviewerGroups{ + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + "b.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + }, + }, + expected: map[string][]string{ + "@owner1": {"a.txt", "b.txt"}, + }, + }, + { + name: "file with multiple owners", + input: &fakeCodeOwners{ + required: map[string]codeowners.ReviewerGroups{ + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1", "@owner2"}}}, + }, + }, + expected: map[string][]string{ + "@owner1": {"a.txt"}, + "@owner2": {"a.txt"}, + }, + }, + { + name: "complex case with optional and duplicates", + input: &fakeCodeOwners{ + required: map[string]codeowners.ReviewerGroups{ + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + "b.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner2"}}}, + }, + optional: map[string]codeowners.ReviewerGroups{ + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner2"}}}, + "c.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + }, + }, + expected: map[string][]string{ + "@owner1": {"a.txt", "c.txt"}, + "@owner2": {"a.txt", "b.txt"}, + }, + }, + { + name: "files are sorted for an owner", + input: &fakeCodeOwners{ + required: map[string]codeowners.ReviewerGroups{ + "z.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + "a.txt": {&codeowners.ReviewerGroup{Names: []string{"@owner1"}}}, + }, + }, + expected: map[string][]string{ + "@owner1": {"a.txt", "z.txt"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := mapOwnersToFiles(tc.input) + + if len(got) != len(tc.expected) { + t.Errorf("mapOwnersToFiles() got %d owners, want %d. Got: %v, want: %v", len(got), len(tc.expected), got, tc.expected) + } + + for owner, wantFiles := range tc.expected { + gotFiles, ok := got[owner] + if !ok { + t.Errorf("mapOwnersToFiles() missing owner %s", owner) + continue + } + if !slices.Equal(gotFiles, wantFiles) { + t.Errorf("mapOwnersToFiles() for owner %s got %v, want %v", owner, gotFiles, wantFiles) + } + } + }) + } +} + func TestWalkRepoFiles(t *testing.T) { testRepo, cleanup := setupTestRepo(t) defer cleanup()