Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ Use the following environment variables to configure the application:
| <tt>OWNER_CHECKER_IGNORED_OWNERS</tt> | `@ghost` | The comma-separated list of owners that should not be validated. Example: `"@owner1,@owner2,@org/team1,example@email.com"`. |
| <tt>OWNER_CHECKER_ALLOW_UNOWNED_PATTERNS</tt> | `true` | Specifies whether CODEOWNERS may have unowned files. For example: <br> <br> `/infra/oncall-rotator/ @sre-team` <br> `/infra/oncall-rotator/oncall-config.yml` <br> <br> The `/infra/oncall-rotator/oncall-config.yml` file is not owned by anyone. |
| <tt>OWNER_CHECKER_OWNERS_MUST_BE_TEAMS</tt> | `false` | Specifies whether only teams are allowed as owners of files. |
| <tt>OWNER_CHECKER_ALLOW_CROSS_ORG_TEAMS</tt> | `false` | Specifies whether teams from different organizations are allowed. When set to `true`, the validator will: skip the organization check, validate team existence in their actual organization, and skip permission checks for cross-org teams (as permissions can't be verified across orgs). This is useful during transitions between organizations. |
| <tt>NOT_OWNED_CHECKER_SKIP_PATTERNS</tt> | | The comma-separated list of patterns that should be ignored by `not-owned-checker`. For example, you can specify `*` and as a result, the `*` pattern from the **CODEOWNERS** file will be ignored and files owned by this pattern will be reported as unowned unless a later specific pattern will match that path. It's useful because often we have default owners entry at the begging of the CODOEWNERS file, e.g. `* @global-owner1 @global-owner2` |
| <tt>NOT_OWNED_CHECKER_SUBDIRECTORIES</tt> | | The comma-separated list of subdirectories to check in `not-owned-checker`. When specified, only files in the listed subdirectories will be checked if they do not have specified owners in CODEOWNERS. |
| <tt>NOT_OWNED_CHECKER_TRUST_WORKSPACE</tt> | `false` | Specifies whether the repository path should be marked as safe. See: https://github.com/actions/checkout/issues/766. |
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ inputs:
default: "false"
required: false

owner_checker_allow_cross_org_teams:
description: "Specifies whether teams from different organizations are allowed. When set to true, the validator will: skip the organization check, validate team existence in their actual organization, and skip permission checks for cross-org teams (as permissions can't be verified across orgs). This is useful during transitions between organizations."
default: "false"
required: false

not_owned_checker_subdirectories:
description: "Only check listed subdirectories for CODEOWNERS ownership that don't have owners."
required: false
Expand Down
6 changes: 6 additions & 0 deletions docs/gh-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ jobs:
# Specifies whether only teams are allowed as owners of files.
owner_checker_owners_must_be_teams: "false"

# Specifies whether teams from different organizations are allowed. When set to true,
# the validator will: skip the organization check, validate team existence in their
# actual organization, and skip permission checks for cross-org teams (as permissions
# can't be verified across orgs). This is useful during transitions between organizations.
owner_checker_allow_cross_org_teams: "false"

# Only check listed subdirectories for CODEOWNERS ownership that don't have owners.
not_owned_checker_subdirectories: ""
```
Expand Down
78 changes: 60 additions & 18 deletions internal/check/valid_owner.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ type ValidOwnerConfig struct {
AllowUnownedPatterns bool `envconfig:"default=true"`
// OwnersMustBeTeams specifies whether owners must be teams in the same org as the repository
OwnersMustBeTeams bool `envconfig:"default=false"`
// AllowCrossOrgTeams specifies whether teams from different organizations are allowed.
// When set to true, the validator will:
// - Skip the organization membership check
// - Validate team existence in their actual organization
// - Skip permission checks for cross-org teams (can't verify across orgs)
// This is useful during transitions between organizations.
AllowCrossOrgTeams bool `envconfig:"default=false"`
}

// ValidOwner validates each owner
Expand All @@ -51,6 +58,7 @@ type ValidOwner struct {
ignOwners map[string]struct{}
allowUnownedPatterns bool
ownersMustBeTeams bool
allowCrossOrgTeams bool
}

// NewValidOwner returns new instance of the ValidOwner
Expand All @@ -73,6 +81,7 @@ func NewValidOwner(cfg ValidOwnerConfig, ghClient *github.Client, checkScopes bo
ignOwners: ignOwners,
allowUnownedPatterns: cfg.AllowUnownedPatterns,
ownersMustBeTeams: cfg.OwnersMustBeTeams,
allowCrossOrgTeams: cfg.AllowCrossOrgTeams,
}, nil
}

Expand Down Expand Up @@ -171,23 +180,32 @@ func (v *ValidOwner) selectValidateFn(name string) func(context.Context, string)
}

func (v *ValidOwner) initOrgListTeams(ctx context.Context) *validateError {
teams, err := v.fetchTeamsForOrg(ctx, v.orgName)
if err != nil {
return err
}
v.orgTeams = teams
return nil
}

func (v *ValidOwner) fetchTeamsForOrg(ctx context.Context, orgName string) ([]*github.Team, *validateError) {
var teams []*github.Team
req := &github.ListOptions{
PerPage: 100,
}
for {
resultPage, resp, err := v.ghClient.Teams.ListTeams(ctx, v.orgName, req)
resultPage, resp, err := v.ghClient.Teams.ListTeams(ctx, orgName, req)
if err != nil { // TODO(mszostok): implement retry?
switch err := err.(type) {
case *github.ErrorResponse:
if err.Response.StatusCode == http.StatusUnauthorized {
return newValidateError("Teams for organization %q could not be queried. Requires GitHub authorization.", v.orgName)
return nil, newValidateError("Teams for organization %q could not be queried. Requires GitHub authorization.", orgName)
}
return newValidateError("HTTP error occurred while calling GitHub: %v", err)
return nil, newValidateError("HTTP error occurred while calling GitHub: %v", err)
case *github.RateLimitError:
return newValidateError("GitHub rate limit reached: %v", err.Message)
return nil, newValidateError("GitHub rate limit reached: %v", err.Message)
default:
return newValidateError("Unknown error occurred while calling GitHub: %v", err)
return nil, newValidateError("Unknown error occurred while calling GitHub: %v", err)
}
}
teams = append(teams, resultPage...)
Expand All @@ -197,41 +215,65 @@ func (v *ValidOwner) initOrgListTeams(ctx context.Context) *validateError {
req.Page = resp.NextPage
}

v.orgTeams = teams

return nil
return teams, nil
}

func (v *ValidOwner) validateTeam(ctx context.Context, name string) *validateError {
if v.orgTeams == nil {
if err := v.initOrgListTeams(ctx); err != nil {
return err.AsPermanent()
}
}

// called after validation it's safe to work on `parts` slice
parts := strings.SplitN(name, "/", 2)
org := parts[0]
org = strings.TrimPrefix(org, "@")
team := parts[1]

// GitHub normalizes name before comparison
if !strings.EqualFold(org, v.orgName) {
// Skip org check if cross-org teams are allowed
if !v.allowCrossOrgTeams && !strings.EqualFold(org, v.orgName) {
return newValidateError("Team %q does not belong to %q organization.", name, v.orgName)
}

// Determine which organization's teams to check
targetOrg := v.orgName
if v.allowCrossOrgTeams {
targetOrg = org // Use the team's actual organization
}

// If we're checking a different org than what's cached, we need to fetch those teams
var teamsToCheck []*github.Team
if strings.EqualFold(targetOrg, v.orgName) {
// Check against the repository's org (use cached teams)
if v.orgTeams == nil {
if err := v.initOrgListTeams(ctx); err != nil {
return err.AsPermanent()
}
}
teamsToCheck = v.orgTeams
} else {
// Need to fetch teams from the different organization
var err *validateError
teamsToCheck, err = v.fetchTeamsForOrg(ctx, targetOrg)
if err != nil {
return err.AsPermanent()
}
}

teamExists := func() bool {
for _, v := range v.orgTeams {
for _, t := range teamsToCheck {
// GitHub normalizes name before comparison
if strings.EqualFold(v.GetSlug(), team) {
if strings.EqualFold(t.GetSlug(), team) {
return true
}
}
return false
}

if !teamExists() {
return newValidateError("Team %q does not exist in organization %q.", name, org)
return newValidateError("Team %q does not exist in organization %q.", name, targetOrg)
}

// Skip permissions check for cross-org teams
// We can't verify permissions for teams in other organizations
if v.allowCrossOrgTeams && !strings.EqualFold(org, v.orgName) {
return nil
}

// repo contains the permissions for the team slug given
Expand Down
52 changes: 52 additions & 0 deletions internal/check/valid_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,55 @@ func TestValidOwnerCheckerOwnersMustBeTeams(t *testing.T) {
})
}
}

func TestValidOwnerCheckerAllowCrossOrgTeams(t *testing.T) {
// This test validates the AllowCrossOrgTeams config flag behavior
// Note: This test only validates the configuration parsing and basic team syntax validation
// The actual org validation requires a GitHub client and is tested in integration tests

t.Run("Config with AllowCrossOrgTeams enabled", func(t *testing.T) {
// given
ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{
Repository: "org/repo",
AllowUnownedPatterns: true,
AllowCrossOrgTeams: true, // Enable cross-org teams
}, nil, true)
require.NoError(t, err)

// Verify the config was properly set
assert.NotNil(t, ownerCheck)
})

t.Run("Config with AllowCrossOrgTeams disabled", func(t *testing.T) {
// given
ownerCheck, err := check.NewValidOwner(check.ValidOwnerConfig{
Repository: "org/repo",
AllowUnownedPatterns: true,
AllowCrossOrgTeams: false, // Disable cross-org teams (default)
}, nil, true)
require.NoError(t, err)

// Verify the config was properly set
assert.NotNil(t, ownerCheck)
})

t.Run("Team syntax validation", func(t *testing.T) {
// Test that team syntax is recognized correctly regardless of org
tests := []struct {
owner string
isValid bool
}{
{"@org/team", true},
{"@different-org/team", true},
{"@altana-poc/team", true},
{"@altana-tech/team", true},
{"@org/", false},
{"org/team", false},
}

for _, tc := range tests {
result := check.IsValidOwner(tc.owner)
assert.Equal(t, tc.isValid, result, "Owner %s validation failed", tc.owner)
}
})
}