diff --git a/README.md b/README.md index 874a51a..e5f930c 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,9 @@ export REPO_PULL="external-auditors" # Read/pull permissions # Optional: restrict to repos whose names start with a given prefix export REPO_NAME_FILTER="my-service-" + +# Optional: skip specific repos for a given permission level (space-separated repo names) +export REPO_PULL_EXCLUDE="secret-repo internal-tools" # These repos won't get pull access ``` **Usage:** @@ -178,6 +181,7 @@ cd org-admin/github-add-repo-permissions - Filters to repositories whose names start with `REPO_NAME_FILTER` (all repos when unset) - Grants permissions to specified teams based on permission level - Supports multiple teams per permission level (space-separated) +- Skips repos listed in the matching `REPO__EXCLUDE` variable for that permission level only - Processes all five GitHub permission levels: admin, maintain, push, triage, pull - Includes 5-second delays between repos to avoid rate limits @@ -190,6 +194,8 @@ cd org-admin/github-add-repo-permissions > [!NOTE] > At least one permission level must be set. Team slugs should be lowercase and hyphenated (e.g., "Platform Team" → `platform-team`). +> +> Each permission level has an optional exclusion list: `REPO_ADMIN_EXCLUDE`, `REPO_MAINTAIN_EXCLUDE`, `REPO_PUSH_EXCLUDE`, `REPO_TRIAGE_EXCLUDE`, and `REPO_PULL_EXCLUDE`. Each holds space-separated exact repo names that are skipped for that level only. --- diff --git a/org-admin/github-add-repo-permissions/action.yml b/org-admin/github-add-repo-permissions/action.yml index d6d90c4..3cf9d8d 100644 --- a/org-admin/github-add-repo-permissions/action.yml +++ b/org-admin/github-add-repo-permissions/action.yml @@ -31,6 +31,26 @@ inputs: description: 'Space-separated team slugs to grant pull access' required: false default: '' + repo-admin-exclude: + description: 'Space-separated repo names to skip for admin access' + required: false + default: '' + repo-maintain-exclude: + description: 'Space-separated repo names to skip for maintain access' + required: false + default: '' + repo-push-exclude: + description: 'Space-separated repo names to skip for push access' + required: false + default: '' + repo-triage-exclude: + description: 'Space-separated repo names to skip for triage access' + required: false + default: '' + repo-pull-exclude: + description: 'Space-separated repo names to skip for pull access' + required: false + default: '' api-url-prefix: description: 'GitHub API base URL' required: false @@ -49,5 +69,10 @@ runs: REPO_PUSH: ${{ inputs.repo-push }} REPO_TRIAGE: ${{ inputs.repo-triage }} REPO_PULL: ${{ inputs.repo-pull }} + REPO_ADMIN_EXCLUDE: ${{ inputs.repo-admin-exclude }} + REPO_MAINTAIN_EXCLUDE: ${{ inputs.repo-maintain-exclude }} + REPO_PUSH_EXCLUDE: ${{ inputs.repo-push-exclude }} + REPO_TRIAGE_EXCLUDE: ${{ inputs.repo-triage-exclude }} + REPO_PULL_EXCLUDE: ${{ inputs.repo-pull-exclude }} API_URL_PREFIX: ${{ inputs.api-url-prefix }} run: ${{ github.action_path }}/github-add-repo-permissions.sh diff --git a/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh b/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh index fa40f8e..6be4bea 100755 --- a/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh +++ b/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh @@ -15,18 +15,24 @@ # ./github-add-repo-permissions.sh # # Environment variables: -# GITHUB_TOKEN Required. PAT with admin:org scope -# ORG Required. GitHub organization name -# REPO_NAME_FILTER Optional. Prefix filter for repository names (default: all repos) -# REPO_ADMIN Optional. Space-separated team slugs to grant admin access -# REPO_MAINTAIN Optional. Space-separated team slugs to grant maintain access -# REPO_PUSH Optional. Space-separated team slugs to grant push access -# REPO_TRIAGE Optional. Space-separated team slugs to grant triage access -# REPO_PULL Optional. Space-separated team slugs to grant pull access -# API_URL_PREFIX Optional. GitHub API base URL (default: https://api.github.com) +# GITHUB_TOKEN Required. PAT with admin:org scope +# ORG Required. GitHub organization name +# REPO_NAME_FILTER Optional. Prefix filter for repository names (default: all repos) +# REPO_ADMIN Optional. Space-separated team slugs to grant admin access +# REPO_MAINTAIN Optional. Space-separated team slugs to grant maintain access +# REPO_PUSH Optional. Space-separated team slugs to grant push access +# REPO_TRIAGE Optional. Space-separated team slugs to grant triage access +# REPO_PULL Optional. Space-separated team slugs to grant pull access +# REPO_ADMIN_EXCLUDE Optional. Space-separated repo names to skip for admin access +# REPO_MAINTAIN_EXCLUDE Optional. Space-separated repo names to skip for maintain access +# REPO_PUSH_EXCLUDE Optional. Space-separated repo names to skip for push access +# REPO_TRIAGE_EXCLUDE Optional. Space-separated repo names to skip for triage access +# REPO_PULL_EXCLUDE Optional. Space-separated repo names to skip for pull access +# API_URL_PREFIX Optional. GitHub API base URL (default: https://api.github.com) # # Note: At least one of REPO_ADMIN, REPO_MAINTAIN, REPO_PUSH, REPO_TRIAGE, -# or REPO_PULL must be set. +# or REPO_PULL must be set. Each REPO_*_EXCLUDE list names repositories +# that are skipped for that permission level only. # # Requirements: # - curl @@ -52,6 +58,13 @@ REPO_PUSH=${REPO_PUSH:-''} REPO_TRIAGE=${REPO_TRIAGE:-''} REPO_PULL=${REPO_PULL:-''} +# Permission-specific exclusion lists (space-separated repo names to skip) +REPO_ADMIN_EXCLUDE=${REPO_ADMIN_EXCLUDE:-''} +REPO_MAINTAIN_EXCLUDE=${REPO_MAINTAIN_EXCLUDE:-''} +REPO_PUSH_EXCLUDE=${REPO_PUSH_EXCLUDE:-''} +REPO_TRIAGE_EXCLUDE=${REPO_TRIAGE_EXCLUDE:-''} +REPO_PULL_EXCLUDE=${REPO_PULL_EXCLUDE:-''} + require_env_var GITHUB_TOKEN "GitHub token" require_env_var ORG "GitHub organization" require_command jq @@ -68,6 +81,35 @@ if [ -n "${REPO_NAME_FILTER}" ]; then print_status "Repository filter: ${REPO_NAME_FILTER}*" fi +is_excluded () { + local REPO_NAME=$1 + local EXCLUDE_LIST=$2 + local EXCLUDED + + for EXCLUDED in ${EXCLUDE_LIST}; do + if [ "${EXCLUDED}" = "${REPO_NAME}" ]; then + return 0 + fi + done + return 1 +} + +apply_level () { + local REPO_NAME=$1 + local PERMISSION=$2 + local TEAM_SLUGS=$3 + local EXCLUDE_LIST=$4 + + [ -z "${TEAM_SLUGS}" ] && return 0 + + if is_excluded "${REPO_NAME}" "${EXCLUDE_LIST}"; then + print_status " Skipping ${PERMISSION} on ${REPO_NAME} (excluded)" + return 0 + fi + + apply_team_permissions "${REPO_NAME}" "${PERMISSION}" "${TEAM_SLUGS}" +} + apply_team_permissions () { local REPO_NAME=$1 local PERMISSION=$2 @@ -107,32 +149,13 @@ process_repos () { while IFS= read -r REPO; do [ -z "${REPO}" ] && continue print_status "Processing repo ${REPO}" - - # Apply admin permissions - if [ -n "${REPO_ADMIN}" ]; then - apply_team_permissions "${REPO}" "admin" "${REPO_ADMIN}" - fi - - # Apply maintain permissions - if [ -n "${REPO_MAINTAIN}" ]; then - apply_team_permissions "${REPO}" "maintain" "${REPO_MAINTAIN}" - fi - - # Apply push (write) permissions - if [ -n "${REPO_PUSH}" ]; then - apply_team_permissions "${REPO}" "push" "${REPO_PUSH}" - fi - - # Apply triage permissions - if [ -n "${REPO_TRIAGE}" ]; then - apply_team_permissions "${REPO}" "triage" "${REPO_TRIAGE}" - fi - - # Apply pull (read) permissions - if [ -n "${REPO_PULL}" ]; then - apply_team_permissions "${REPO}" "pull" "${REPO_PULL}" - fi - + + apply_level "${REPO}" "admin" "${REPO_ADMIN}" "${REPO_ADMIN_EXCLUDE}" + apply_level "${REPO}" "maintain" "${REPO_MAINTAIN}" "${REPO_MAINTAIN_EXCLUDE}" + apply_level "${REPO}" "push" "${REPO_PUSH}" "${REPO_PUSH_EXCLUDE}" + apply_level "${REPO}" "triage" "${REPO_TRIAGE}" "${REPO_TRIAGE_EXCLUDE}" + apply_level "${REPO}" "pull" "${REPO_PULL}" "${REPO_PULL_EXCLUDE}" + # Add delay to prevent hitting GitHub rate limit sleep 5 done < <(echo "${repos_json}" | jq -r --arg filter "${REPO_NAME_FILTER}" 'sort_by(.name) | .[] | select(.name | startswith($filter)) | .name') diff --git a/tests/mock_curl_permissions.sh b/tests/mock_curl_permissions.sh new file mode 100644 index 0000000..45a4da8 --- /dev/null +++ b/tests/mock_curl_permissions.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# ============================================================================= +# mock_curl_permissions.sh +# +# Purpose-built curl mock for github-add-repo-permissions tests. Unlike the +# universal mock_curl.sh, this variant returns endpoint-specific responses so +# the full repo-processing flow can be exercised: +# +# HEAD request (-I) page-count probe -> single page (no next header) +# GET .../user (-o target) token validation -> HTTP 200 +# PUT team permissions -> HTTP 204 +# GET .../repos repo list -> JSON array from MOCK_REPOS_JSON +# +# MOCK_REPOS_JSON overrides the repo list body (default: two repos). +# ============================================================================= + +is_put=0 +is_head=0 +ofile="" +prev="" +for arg in "$@"; do + case "${arg}" in + PUT) is_put=1 ;; + -I) is_head=1 ;; + esac + [ "${prev}" = "-o" ] && ofile="${arg}" + prev="${arg}" +done + +if [ "${is_put}" = 1 ]; then + printf '204' + exit 0 +fi + +if [ "${is_head}" = 1 ]; then + printf 'HTTP/1.1 200\r\n' + exit 0 +fi + +if [ -n "${ofile}" ]; then + : > "${ofile}" + printf '200' + exit 0 +fi + +DEFAULT_REPOS='[{"name":"repo-keep"},{"name":"repo-skip"}]' +printf '%s' "${MOCK_REPOS_JSON:-${DEFAULT_REPOS}}" diff --git a/tests/test_script_validation.bats b/tests/test_script_validation.bats index 46e00e1..fbc8f6c 100644 --- a/tests/test_script_validation.bats +++ b/tests/test_script_validation.bats @@ -86,10 +86,35 @@ _run_script() { } @test "github-add-repo-permissions: exits 1 when ORG is not set" { - _run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; unset ORG;" + _run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; export REPO_PULL=read-team; unset ORG;" [ "$status" -eq 1 ] } +@test "github-add-repo-permissions: exits 1 when no permission level is set" { + _run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; export ORG=test;" + [ "$status" -eq 1 ] +} + +@test "github-add-repo-permissions: skips repo listed in REPO_PULL_EXCLUDE for pull only" { + cp "${BATS_TEST_DIRNAME}/mock_curl_permissions.sh" "$MOCK_BIN/curl" + chmod +x "$MOCK_BIN/curl" + _run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" \ + "export GITHUB_TOKEN=fake; export ORG=test; export REPO_PULL=read-team; export REPO_PULL_EXCLUDE=repo-skip;" + [ "$status" -eq 0 ] + [[ "$output" == *"Skipping pull on repo-skip (excluded)"* ]] + [[ "$output" == *"Applied pull to read-team on repo-keep"* ]] +} + +@test "github-add-repo-permissions: applies to all repos when exclude list is empty" { + cp "${BATS_TEST_DIRNAME}/mock_curl_permissions.sh" "$MOCK_BIN/curl" + chmod +x "$MOCK_BIN/curl" + _run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" \ + "export GITHUB_TOKEN=fake; export ORG=test; export REPO_PULL=read-team;" + [ "$status" -eq 0 ] + [[ "$output" == *"Applied pull to read-team on repo-keep"* ]] + [[ "$output" == *"Applied pull to read-team on repo-skip"* ]] +} + # ═══════════════════════════════════════════════════════════════════════════════ # org-admin/github-archive-old-repos # ═══════════════════════════════════════════════════════════════════════════════