Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.idea/
.claude/
97 changes: 97 additions & 0 deletions scripts/check-codeowners.bats
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,78 @@ teardown() {
[ -z "$output" ]
}

# ---------------------------------------------------------------------------
# load_allowlist
# ---------------------------------------------------------------------------

@test "load_allowlist: empty output for empty file" {
touch "$FIXTURE_DIR/allowlist.txt"
run load_allowlist "$FIXTURE_DIR/allowlist.txt"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "load_allowlist: empty output for missing file" {
run load_allowlist "$FIXTURE_DIR/does-not-exist.txt"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "load_allowlist: ignores comments and blank lines" {
printf "# a comment\n\n# another\n \n" > "$FIXTURE_DIR/allowlist.txt"
run load_allowlist "$FIXTURE_DIR/allowlist.txt"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "load_allowlist: returns entries from a mixed file" {
printf "# header\n@bot-one\n\n@bot-two\n# trailing\n" > "$FIXTURE_DIR/allowlist.txt"
run load_allowlist "$FIXTURE_DIR/allowlist.txt"
[ "$status" -eq 0 ]
[ "$output" = "@bot-one"$'\n'"@bot-two" ]
}

@test "load_allowlist: trims surrounding whitespace" {
printf " @spaced-bot \n\t@tabbed-bot\t\n" > "$FIXTURE_DIR/allowlist.txt"
run load_allowlist "$FIXTURE_DIR/allowlist.txt"
[ "$status" -eq 0 ]
[ "$output" = "@spaced-bot"$'\n'"@tabbed-bot" ]
}

# ---------------------------------------------------------------------------
# filter_against_allowlist
# ---------------------------------------------------------------------------

@test "filter_against_allowlist: empty allowlist preserves all input" {
run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bob\n" | filter_against_allowlist ""'
[ "$status" -eq 0 ]
[ "$output" = "@alice"$'\n'"@bob" ]
}

@test "filter_against_allowlist: removes matching entries" {
run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bot\n@bob\n" | filter_against_allowlist "@bot"'
[ "$status" -eq 0 ]
[ "$output" = "@alice"$'\n'"@bob" ]
}

@test "filter_against_allowlist: removes multiple matches" {
run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bot-one\n@bob\n@bot-two\n" | filter_against_allowlist "@bot-one"$'"'"'\n'"'"'"@bot-two"'
[ "$status" -eq 0 ]
[ "$output" = "@alice"$'\n'"@bob" ]
}

@test "filter_against_allowlist: empty output when all entries are allowlisted" {
run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@bot\n" | filter_against_allowlist "@bot"'
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "filter_against_allowlist: empty input passes through cleanly" {
run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "" | filter_against_allowlist "@bot"'
[ "$status" -eq 0 ]
[ -z "$output" ]
}

# ---------------------------------------------------------------------------
# find_codeowners
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -122,3 +194,28 @@ teardown() {
run main "$FIXTURE_DIR"
[ "$status" -eq 0 ]
}

@test "main: passes when only allowlisted individual is present" {
printf "@allowed-bot\n" > "$FIXTURE_DIR/allowlist.txt"
printf "* @org/team @allowed-bot\n" > "$FIXTURE_DIR/CODEOWNERS"
CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/allowlist.txt" run main "$FIXTURE_DIR"
[ "$status" -eq 0 ]
[[ "$output" == *"Allowed via allowlist: @allowed-bot"* ]]
}

@test "main: fails on disallowed individual but reports allowlisted exemption" {
printf "@allowed-bot\n" > "$FIXTURE_DIR/allowlist.txt"
printf "* @org/team @allowed-bot @human\n" > "$FIXTURE_DIR/CODEOWNERS"
CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/allowlist.txt" run main "$FIXTURE_DIR"
[ "$status" -eq 1 ]
[[ "$output" == *"Allowed via allowlist: @allowed-bot"* ]]
[[ "$output" == *"@human"* ]]
[[ "$output" != *"Offending entries:"*"@allowed-bot"* ]]
}

@test "main: fails for non-allowlisted individual when allowlist file is missing" {
printf "* @human\n" > "$FIXTURE_DIR/CODEOWNERS"
CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/does-not-exist.txt" run main "$FIXTURE_DIR"
[ "$status" -eq 1 ]
[[ "$output" == *"@human"* ]]
}
51 changes: 47 additions & 4 deletions scripts/check-codeowners.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ extract_individuals() {
|| true
}

# Load allowlist entries from FILE. Strips full-line comments (# prefix after
# trimming), blank lines, and surrounding whitespace. Missing file → empty
# output (no error) so removing the allowlist falls back to strict enforcement.
load_allowlist() {
local file="$1"
[[ -f "$file" ]] || return 0
awk '
{ gsub(/^[[:space:]]+|[[:space:]]+$/, "") }
/^$/ { next }
/^#/ { next }
{ print }
' "$file"
}

# Filter stdin against ALLOWLIST (newline-separated). Drops exact matches,
# prints survivors. Empty allowlist → passthrough.
filter_against_allowlist() {
local allowlist="$1"
if [[ -z "$allowlist" ]]; then
cat
return 0
fi
grep -Fxv -f <(printf '%s\n' "$allowlist") || true
}

main() {
local root="${1:-.}"
local codeowners_file
Expand All @@ -36,18 +61,36 @@ main() {

echo "Checking $codeowners_file ..."

local individuals
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local allowlist_file="${CODEOWNERS_ALLOWLIST_FILE:-${script_dir}/codeowners-allowlist.txt}"
local allowlist
allowlist=$(load_allowlist "$allowlist_file")

local individuals disallowed allowed_matches
individuals=$(extract_individuals "$codeowners_file")

if [[ -n "$individuals" ]]; then
if [[ -z "$individuals" ]]; then
echo "All CODEOWNERS entries are team references."
return 0
fi

disallowed=$(printf '%s\n' "$individuals" | filter_against_allowlist "$allowlist")
allowed_matches=$(printf '%s\n' "$individuals" | { [[ -n "$allowlist" ]] && grep -Fxf <(printf '%s\n' "$allowlist") || true; })

if [[ -n "$allowed_matches" ]]; then
echo "Allowed via allowlist: $(echo "$allowed_matches" | paste -sd, -)"
fi

if [[ -n "$disallowed" ]]; then
echo "::error file=$codeowners_file::Individual users are not permitted in CODEOWNERS — use @org/team references instead."
echo ""
echo "Offending entries:"
echo "$individuals"
echo "$disallowed"
exit 1
fi

echo "All CODEOWNERS entries are team references."
echo "All CODEOWNERS entries are team references or allowlisted."
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
Expand Down
10 changes: 10 additions & 0 deletions scripts/codeowners-allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Individual @mentions that are exempt from the "teams only" CODEOWNERS policy
# enforced by scripts/check-codeowners.sh.
#
# Add only bot/service accounts — never humans. Humans must own code via team
# membership so that ownership survives org changes and PTO.
#
# One @username per line. Blank lines and # comments are ignored.
# Entries are matched exactly against tokens extracted from CODEOWNERS.

@collibra-cicd-auto-merge