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 25f8d0b..56c1c53 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) + }, + }, }, } @@ -192,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 != "" { @@ -213,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 _, repoFile := range allRepoFiles { + file := repoFile.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, repoFile) } - 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) } @@ -408,6 +449,101 @@ 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) + } + + files, err := walkRepoFiles(repo) + if err != nil { + return 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 +} + +// 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) + + for file, reviewerGroups := range allFileOwners { + // Flatten, de-duplicate, and sort the owners. + owners := f.RemoveDuplicates(reviewerGroups.Flatten()) + if len(owners) > 0 { + slices.Sort(owners) + fileToOwners[file] = owners + } + } + 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 { + allFileOwners := getAllFileOwners(ownersMap) + ownerToFilesSet := make(map[string]map[string]struct{}) + + for file, reviewerGroups := range allFileOwners { + for _, owner := range reviewerGroups.Flatten() { + if _, ok := ownerToFilesSet[owner]; !ok { + ownerToFilesSet[owner] = make(map[string]struct{}) + } + ownerToFilesSet[owner][file] = struct{}{} + } + } + + 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 +} + 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..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" @@ -639,3 +640,235 @@ 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/.codeowners", "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 + } + if !f.SlicesItemsMatch(gotFiles, wantFiles) { + t.Errorf("map by owner: for owner %s, got %v, want %v", owner, gotFiles, wantFiles) + } + } + }) +} + +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() + + 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) + } +}