Skip to content
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-82.6%25-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-82.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)

Expand All @@ -22,6 +22,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better
- [Advanced Configuration](#advanced-configuration)
- [Enforcement Options](#enforcement-options)
- [Quiet Mode](#quiet-mode)
- [Ownership Oracles](#ownership-oracles)
- [CLI Tool](#cli-tool)
- [Contributing](#contributing)
- [Future Features](#future-features)
Expand All @@ -48,6 +49,7 @@ These are features missing from GitHub code owners that are supported by Codeown
* GitHub CODEOWNERS supports only `OR` ownership rules, in contrast
* Directory-level code ownership files to assign fine-grained code ownership
* Supports optional reviewers (cc users/teams for non-blocking reviews)
* Ownership oracles: external tooling can compute additional reviewer requirements from PR content (see [Ownership Oracles](#ownership-oracles))
* Advanced global configuration (see [Advanced Configuration](#advanced-configuration))

## Getting Started
Expand Down Expand Up @@ -372,6 +374,54 @@ Using the `quiet` input on the action will change the behavior in a couple ways:
* **Draft Pull Requests:** This is a common use case. You might want the Codeowners Plus logic to run and report a status (e.g., pending or failed) on draft PRs, but without notifying reviewers prematurely by adding comments or requesting reviews until the PR is marked "Ready for review".
* **Custom Notification Workflows:** You might prefer to handle notifications or review requests through a different mechanism and only use Codeowners Plus for the status check enforcement.

### Ownership Oracles

Some ownership requirements cannot be expressed as path patterns: "changes to telemetry events need data-platform review" depends on what changed inside a file, not which file changed. Ownership oracles let external tooling compute these requirements and feed them to Codeowners Plus as data.

An oracle file is JSON, written by any earlier workflow step:

```json
{
"rules": [
{
"files": ["src/telemetry/**"],
"owners": ["@your-org/data-platform"],
"optional": false,
"reason": "telemetry event schema changed in this PR"
}
]
}
```

* `files`: doublestar glob patterns matched against the full repo-relative paths of files changed in the PR (no leading slash; patterns are already anchored to the repo root, and a leading slash is rejected)
* `owners`: a single OR group where any one of the listed owners satisfies the rule (same semantics as a `.codeowners` line)
* `optional`: when `true`, owners are CC'd instead of required
* `reason`: human-readable explanation, shown in verbose output

Pass oracle files to the action with the `oracle-files` input (comma-separated):

```yaml
- name: 'Detect telemetry changes'
id: detect
run: ./scripts/detect-telemetry-changes.sh > /tmp/telemetry-oracle.json

- name: 'Codeowners Plus'
uses: multimediallc/codeowners-plus@v1
with:
github-token: '${{ secrets.GITHUB_TOKEN }}'
pr: '${{ github.event.pull_request.number }}'
oracle-files: '/tmp/telemetry-oracle.json'
```

Oracle requirements are AND-merged with `.codeowners` requirements, the same mechanism as `require_both_branch_reviewers`: review requesting, approval tracking, smart dismissal, and the status check all apply to oracle-derived owners the same as file-derived owners.

Notes:

* Oracle rules can only add reviewer requirements, never remove or weaken requirements from `.codeowners` files, so a tampered oracle file can at worst request extra reviews.
* The script that *generates* the oracle file is a different story: in a `pull_request`-triggered workflow it runs from the PR checkout, so a PR author could modify it in the same PR to emit no rules. If an oracle enforces security-critical reviews, protect the generator script with a `.codeowners` rule, or run it from the base branch (for example via a `pull_request_target` workflow).
* A missing or malformed oracle file is a hard error (the check fails), since silently skipping one would drop required reviews.
* A file matched by a required (non-`optional`) oracle rule counts as owned, so it is not reported as an unowned file. Optional rules CC reviewers but do not confer ownership.

## CLI Tool

A CLI tool is available which provides some utilities for working with `.codeowners` files.
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ inputs:
description: 'The owner and repository name. For example `octocat/Hello-World`'
required: true
default: '${{ github.repository }}'
oracle-files:
description: 'Comma-separated list of ownership oracle JSON files to AND-merge into .codeowners requirements'
required: false
default: ''
verbose:
description: 'Print debug info'
required: false
Expand Down Expand Up @@ -111,6 +115,7 @@ runs:
INPUT_GITHUB-TOKEN: ${{ inputs.github-token }}
INPUT_PR: ${{ inputs.pr }}
INPUT_REPOSITORY: ${{ inputs.repository }}
INPUT_ORACLE-FILES: ${{ inputs.oracle-files }}
INPUT_VERBOSE: ${{ inputs.verbose }}
INPUT_QUIET: ${{ inputs.quiet }}
BIN: ${{ steps.resolve.outputs.bin }}
Expand Down
34 changes: 34 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
gh "github.com/multimediallc/codeowners-plus/internal/github"
"github.com/multimediallc/codeowners-plus/pkg/codeowners"
f "github.com/multimediallc/codeowners-plus/pkg/functional"
"github.com/multimediallc/codeowners-plus/pkg/oracle"
)

// OutputData holds the data that will be written to GITHUB_OUTPUT
Expand Down Expand Up @@ -55,6 +56,7 @@ type Config struct {
RepoDir string
PR int
Repo string
OracleFiles []string
Verbose bool
Quiet bool
InfoBuffer io.Writer
Expand Down Expand Up @@ -166,6 +168,10 @@ func (a *App) Run() (*OutputData, error) {
return &OutputData{}, fmt.Errorf("NewCodeOwners Error: %v", err)
}
}
codeOwners, err = a.applyOracles(codeOwners, gitDiff)
if err != nil {
return &OutputData{}, err
}
a.codeowners = codeOwners

// Initialize user reviewer map
Expand Down Expand Up @@ -212,6 +218,34 @@ func (a *App) Run() (*OutputData, error) {
return outputData, nil
}

// applyOracles AND-merges ownership from the configured oracle files; a missing or malformed file is a hard error.
func (a *App) applyOracles(codeOwners codeowners.CodeOwners, gitDiff git.Diff) (codeowners.CodeOwners, error) {
if len(a.config.OracleFiles) == 0 {
return codeOwners, nil
}

merged := &oracle.RuleSet{}
for _, path := range a.config.OracleFiles {
ruleSet, err := oracle.Load(path)
if err != nil {
return nil, fmt.Errorf("oracle error: %v", err)
}
merged.Rules = append(merged.Rules, ruleSet.Rules...)
}
if len(merged.Rules) == 0 {
a.printDebug("Oracle files contain no rules\n")
return codeOwners, nil
}

for _, rule := range merged.Rules {
a.printDebug("Oracle rule: files=%v owners=%v optional=%t reason=%q\n", rule.Files, rule.Owners, rule.Optional, rule.Reason)
}

changedFiles := f.Map(gitDiff.AllChanges(), func(file codeowners.DiffFile) string { return file.FileName })
oracleOwners := merged.ToCodeOwners(changedFiles, a.config.WarningBuffer)
return codeowners.MergeCodeOwners(codeOwners, oracleOwners), nil
}

func (a *App) processApprovalsAndReviewers() (bool, string, []string, error) {
message := ""

Expand Down
229 changes: 229 additions & 0 deletions internal/app/oracle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package app

import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/multimediallc/codeowners-plus/pkg/codeowners"
)

func TestNewFromFileOwners(t *testing.T) {
rgm := codeowners.NewReviewerGroupMemo()
co := codeowners.NewFromFileOwners(
map[string]codeowners.ReviewerGroups{
"both.go": {rgm.ToReviewerGroup("@required")},
"required.go": {rgm.ToReviewerGroup("@required"), rgm.ToReviewerGroup("@required")},
},
map[string]codeowners.ReviewerGroups{
"both.go": {rgm.ToReviewerGroup("@optional")},
"optional.go": {rgm.ToReviewerGroup("@optional")},
},
)

if len(co.FileRequired()["both.go"]) != 1 || len(co.FileOptional()["both.go"]) != 1 {
t.Errorf("expected both.go to carry one required and one optional group, got %+v / %+v",
co.FileRequired()["both.go"], co.FileOptional()["both.go"])
}
if len(co.FileRequired()["required.go"]) != 1 {
t.Errorf("expected duplicate groups to be deduplicated, got %d", len(co.FileRequired()["required.go"]))
}
if _, ok := co.FileRequired()["optional.go"]; ok {
t.Error("optional-only file should have no required reviewers")
}
if len(co.UnownedFiles()) != 0 {
t.Errorf("expected no unowned files, got %v", co.UnownedFiles())
}
}

func writeOracleFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oracle.json")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}

func oracleTestApp(oracleFiles []string) *App {
return &App{
config: &Config{
OracleFiles: oracleFiles,
InfoBuffer: &bytes.Buffer{},
WarningBuffer: &bytes.Buffer{},
},
}
}

func baseCodeOwnersForOracleTest() codeowners.CodeOwners {
rgm := codeowners.NewReviewerGroupMemo()
return codeowners.NewFromFileOwners(map[string]codeowners.ReviewerGroups{
"src/telemetry/events.ts": {rgm.ToReviewerGroup("@org/frontend")},
}, nil)
}

func TestApplyOraclesNoFiles(t *testing.T) {
app := oracleTestApp(nil)
base := baseCodeOwnersForOracleTest()
result, err := app.applyOracles(base, mockGitDiff{changes: []string{"src/telemetry/events.ts"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != base {
t.Error("expected base CodeOwners to be returned unchanged when no oracle files are configured")
}
}

func TestApplyOraclesMergesRequirements(t *testing.T) {
path := writeOracleFile(t, `{"rules": [
{"files": ["src/telemetry/**"], "owners": ["@org/data-platform"], "reason": "NR event change"}
]}`)
app := oracleTestApp([]string{path})
base := baseCodeOwnersForOracleTest()

result, err := app.applyOracles(base, mockGitDiff{changes: []string{"src/telemetry/events.ts", "other.go"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

groups := result.FileRequired()["src/telemetry/events.ts"]
names := codeowners.OriginalStrings(groups.Flatten())
if !slices.Contains(names, "@org/frontend") || !slices.Contains(names, "@org/data-platform") {
t.Errorf("expected both base and oracle owners, got %v", names)
}
if len(groups) != 2 {
t.Errorf("expected 2 AND groups, got %d", len(groups))
}
}

func TestApplyOraclesMissingFile(t *testing.T) {
app := oracleTestApp([]string{"/nonexistent/oracle.json"})
_, err := app.applyOracles(baseCodeOwnersForOracleTest(), mockGitDiff{changes: []string{"a.go"}})
if err == nil || !strings.Contains(err.Error(), "oracle error") {
t.Errorf("expected hard error for missing oracle file, got %v", err)
}
}

func TestApplyOraclesMultipleFiles(t *testing.T) {
pathA := writeOracleFile(t, `{"rules": [
{"files": ["src/telemetry/**"], "owners": ["@org/data-platform"]}
]}`)
pathB := writeOracleFile(t, `{"rules": [
{"files": ["src/**"], "owners": ["@org/security"]}
]}`)
app := oracleTestApp([]string{pathA, pathB})
base := baseCodeOwnersForOracleTest()

result, err := app.applyOracles(base, mockGitDiff{changes: []string{"src/telemetry/events.ts"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

groups := result.FileRequired()["src/telemetry/events.ts"]
names := codeowners.OriginalStrings(groups.Flatten())
for _, expected := range []string{"@org/frontend", "@org/data-platform", "@org/security"} {
if !slices.Contains(names, expected) {
t.Errorf("expected owner %s from merged oracle files, got %v", expected, names)
}
}
if len(groups) != 3 {
t.Errorf("expected 3 AND groups (base + one per oracle file), got %d", len(groups))
}
}

func TestApplyOraclesUnownedFileBecomesOwned(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a"), 0o644); err != nil {
t.Fatal(err)
}
diff := mockGitDiff{changes: []string{"a.go"}}
base, err := codeowners.New(dir, diff.AllChanges(), nil, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if len(base.UnownedFiles()) != 1 {
t.Fatalf("expected a.go to start unowned, got %v", base.UnownedFiles())
}

path := writeOracleFile(t, `{"rules": [{"files": ["a.go"], "owners": ["@org/adopters"]}]}`)
app := oracleTestApp([]string{path})
result, err := app.applyOracles(base, diff)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.UnownedFiles()) != 0 {
t.Errorf("oracle-matched file should count as owned, got unowned: %v", result.UnownedFiles())
}
}

func TestApplyOraclesUnmatchedFileStaysUnowned(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"a.go", "b.go"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("package x"), 0o644); err != nil {
t.Fatal(err)
}
}
diff := mockGitDiff{changes: []string{"a.go", "b.go"}}
base, err := codeowners.New(dir, diff.AllChanges(), nil, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if len(base.UnownedFiles()) != 2 {
t.Fatalf("expected both files to start unowned, got %v", base.UnownedFiles())
}

path := writeOracleFile(t, `{"rules": [{"files": ["a.go"], "owners": ["@org/adopters"]}]}`)
app := oracleTestApp([]string{path})
result, err := app.applyOracles(base, diff)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if unowned := result.UnownedFiles(); !slices.Equal(unowned, []string{"b.go"}) {
t.Errorf("expected only b.go to remain unowned, got %v", unowned)
}
}

func TestApplyOraclesOptionalRuleKeepsUnowned(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a"), 0o644); err != nil {
t.Fatal(err)
}
diff := mockGitDiff{changes: []string{"a.go"}}
base, err := codeowners.New(dir, diff.AllChanges(), nil, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if len(base.UnownedFiles()) != 1 {
t.Fatalf("expected a.go to start unowned, got %v", base.UnownedFiles())
}

path := writeOracleFile(t, `{"rules": [{"files": ["a.go"], "owners": ["@org/watchers"], "optional": true}]}`)
app := oracleTestApp([]string{path})
result, err := app.applyOracles(base, diff)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if unowned := result.UnownedFiles(); !slices.Equal(unowned, []string{"a.go"}) {
t.Errorf("optional-only oracle match should not confer ownership, got unowned: %v", unowned)
}
optional := result.FileOptional()["a.go"]
if len(optional) != 1 {
t.Errorf("expected the optional group to still be attached, got %v", optional)
}
}

func TestApplyOraclesEmptyRules(t *testing.T) {
path := writeOracleFile(t, `{"rules": []}`)
app := oracleTestApp([]string{path})
base := baseCodeOwnersForOracleTest()
result, err := app.applyOracles(base, mockGitDiff{changes: []string{"a.go"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != base {
t.Error("expected base CodeOwners to be returned unchanged for empty oracle rules")
}
}
Loading
Loading