#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## AI-Assisted

## Apply project-wide GitHub security/policy settings to the source
## organizations (Kicksecure, Whonix). Idempotent and apply-only.
## Run `dm-github-org-policy --help` for the full settings list.

set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

# shellcheck source=../libexec/developer-meta-files/github-org-lib.bsh
source /usr/libexec/developer-meta-files/github-org-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-lib.bsh
source /usr/libexec/developer-meta-files/github-policy-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-data.bsh
source /usr/libexec/developer-meta-files/github-policy-data.bsh
## R-082: source every helper-scripts file used directly.
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/log_run_die.sh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/log_run_die.sh
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/has.sh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/has.sh
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh

die_if_not_has github-org-fork

## ===== Per-org policy distinction =====
##
## SOURCE = canonical upstream org (Kicksecure, Whonix). Holds
##          originals; first-class bug-tracker.
##          Defaults: has_issues=true, has_projects=true,
##          has_discussions=true.
## MIRROR = fork org (org-ai-assisted) for AI-assisted dev work.
##          Issues / Projects / Discussions belong on SOURCE.
##          Defaults: has_issues=false, has_projects=false,
##          has_discussions=false.
##
## Vocabulary aligns with dm-github-fork-sync.
readonly SOURCE_ORGS=( 'Kicksecure' 'Whonix' )
readonly MIRROR_ORGS=( 'org-ai-assisted' )

## SOURCE_ORGS currently commented out pending an admin token scoped
## to those orgs. ORGS_OVERRIDE (comma-separated, since bash arrays
## cannot cross a process boundary as env vars) lets tests exercise
## the SOURCE pivot:
##   ORGS_OVERRIDE='Whonix' dm-github-org-policy --dry-run
# readonly ORGS=( "${SOURCE_ORGS[@]}" "${MIRROR_ORGS[@]}" )
if [ -n "${ORGS_OVERRIDE:-}" ]; then
   IFS=',' read -ra ORGS <<< "${ORGS_OVERRIDE}"
else
   ORGS=( "${MIRROR_ORGS[@]}" )
fi
readonly ORGS

## Echo 'source' or 'mirror' for the named org; die on unknown. A
## typo'd org would otherwise silently get the wrong defaults.
org_kind() {
   local org name
   org="$1"
   for name in "${SOURCE_ORGS[@]}"; do
      [ "${name}" = "${org}" ] && { printf '%s\n' source; return 0; }
   done
   for name in "${MIRROR_ORGS[@]}"; do
      [ "${name}" = "${org}" ] && { printf '%s\n' mirror; return 0; }
   done
   log error "org_kind: '${org}' not in SOURCE_ORGS or MIRROR_ORGS"
   return 1
}

## Used at repo level (Free, applied per-repo) and at org level (PAID
## PLAN ONLY, commented out). Each name is unique within its scope;
## sharing makes a future plan upgrade trivial.
readonly RULESET_NAME_BRANCH='dm-github-org-policy default-branch protection'
readonly RULESET_NAME_TAG='dm-github-org-policy tag protection'
## PAID PLAN ONLY.
# readonly CODE_SEC_CONFIG_NAME='dm-github-org-policy code security'


dry_run=0
verbose=0
mode=''
mode_set=0
positional=()

show_help() {
   cat <<'EOF'
Apply project-wide GitHub security/policy settings to the source
organizations (Kicksecure, Whonix) and the mirror org
(org-ai-assisted). Idempotent and apply-only - never disables a
setting that was already on.

Usage (one mode flag is required - no implicit default):
  dm-github-org-policy --apply         apply policy
  dm-github-org-policy --dry-run       report planned changes only
  dm-github-org-policy --audit         read-only inspection
  dm-github-org-policy --policy-dump   print the active policy table
                                       (cats github-policy-data.bsh)
  dm-github-org-policy --help

Run 'dm-github-org-policy --policy-dump' for the full table of what
gets applied (bodies, endpoints, methods, labels, SOURCE vs MIRROR
diffs).

Environment variable:
ORGS_OVERRIDE=Kicksecure,Whonix

Auth:
  ${GITHUB_TOKEN} env var, or chmod-600 ~/.config/github-token.
  Classic PAT needs admin:org + repo. Fine-grained PAT needs
  Administration r/w + Code security r/w + Webhooks r/w +
  Members r/w (the last for 2FA enforcement audit).
EOF
}

policy_tool_init 0 "$@"

## See agents/github-policy-canonical-vs-mirror.md "Free-plan
## code-security replacements". Free-compatible per-repo endpoints
## used in lieu of the org-level configuration API:
##   - PUT /repos/{owner}/{repo}/vulnerability-alerts
##   - PUT /repos/{owner}/{repo}/automated-security-fixes
##   - PUT /repos/{owner}/{repo}/private-vulnerability-reporting
##   - PATCH /repos/{owner}/{repo} with security_and_analysis.{
##       secret_scanning, secret_scanning_push_protection
##     }.status = "enabled"
# apply_code_security_config() {
#    local org config_body existing_id list_body create_body
#    local attach_endpoint default_endpoint
#
#    org="$1"
#
#    config_body="$(ghorg_jq -n --arg name "${CODE_SEC_CONFIG_NAME}" -- \
#       "${POLICY_CODE_SECURITY} + {name: \$name}")"
#
#    if [ "${dry_run}" = '1' ]; then
#       log notice "DRY-RUN: ${org}: upsert code-security configuration ${CODE_SEC_CONFIG_NAME}"
#       log notice "DRY-RUN: ${org}: attach code-security configuration scope=all"
#       log notice "DRY-RUN: ${org}: set code-security configuration default_for_new_repos=all"
#       return 0
#    fi
#
#    policy_api_call "${org}: list code-security configurations" \
#       GET "/orgs/${org}/code-security/configurations?per_page=100" '' '' list_body \
#       || return 1
#
#    existing_id="$(printf '%s' "${list_body}" \
#       | ghorg_jq_capped -r --arg name "${CODE_SEC_CONFIG_NAME}" -- \
#         '.[] | select(.name == $name) | .id' \
#       | head --lines=1)"
#
#    if [ -n "${existing_id}" ] && [ "${existing_id}" != 'null' ]; then
#       if ! [[ "${existing_id}" =~ ^[0-9]+$ ]] || \
#          [ "${#existing_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
#          log warn "'${org}': code-security config id '${existing_id}' not a valid numeric id"
#          policy_warn_seen=1
#          return 1
#       fi
#       policy_api_call "${org}: update code-security configuration id=${existing_id}" \
#          PATCH "/orgs/${org}/code-security/configurations/${existing_id}" \
#          "${config_body}" || true
#    else
#       ## POST returns the new config object including its id, needed
#       ## for the attach + default calls.
#       policy_api_call "${org}: create code-security configuration" \
#          POST "/orgs/${org}/code-security/configurations" "${config_body}" '' create_body \
#          || return 1
#       existing_id="$(printf '%s' "${create_body}" | ghorg_jq_capped -r -- '.id')"
#       if ! [[ "${existing_id}" =~ ^[0-9]+$ ]] || \
#          [ "${#existing_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
#          log warn "'${org}': new code-security config id '${existing_id}' not a valid numeric id"
#          policy_warn_seen=1
#          return 1
#       fi
#       log notice "ok: ${org}: created code-security configuration id=${existing_id}"
#    fi
#
#    ## ATTACH and DEFAULTS endpoints embed the configuration id;
#    ## resolve __ID__ here, then __ORG__ via the dispatcher.
#    attach_endpoint="${POLICY_CODE_SECURITY_ATTACH_ENDPOINT_ORG//__ID__/${existing_id}}"
#    policy_api_call "${org}: ${POLICY_CODE_SECURITY_ATTACH_LABEL}" \
#       "${POLICY_CODE_SECURITY_ATTACH_METHOD}" \
#       "${attach_endpoint//__ORG__/${org}}" \
#       "$(ghorg_jq -n -- "${POLICY_CODE_SECURITY_ATTACH}")" \
#       || true
#
#    default_endpoint="${POLICY_CODE_SECURITY_DEFAULTS_ENDPOINT_ORG//__ID__/${existing_id}}"
#    policy_api_call "${org}: ${POLICY_CODE_SECURITY_DEFAULTS_LABEL}" \
#       "${POLICY_CODE_SECURITY_DEFAULTS_METHOD}" \
#       "${default_endpoint//__ORG__/${org}}" \
#       "$(ghorg_jq -n -- "${POLICY_CODE_SECURITY_DEFAULTS}")" \
#       || true
# }

## Org-level settings dispatch. Code-security configuration and the
## branch + tag rulesets are PAID PLAN ONLY (GHAS / GitHub Team+) and
## commented out with skip lines so a Free-org operator sees what was
## elided.
apply_org_policy() {
   local org kind

   org="$1"
   ## kind is also consumed by the SOURCE-only UI-flip skip lines at
   ## the end of this function; the commented-out PAID-plan ruleset
   ## bypass-actor pivot below would also use it.
   kind="$(org_kind "${org}")" || return 1

   log notice "=== org policy: ${org} ==="

   policy_apply_org "${org}" FORK_PR_APPROVAL

   policy_apply_org "${org}" WORKFLOW_PERMS

   policy_apply_org "${org}" ACTIONS_SCOPE
   policy_apply_org "${org}" ACTIONS_ALLOWLIST ENDPOINT

   policy_apply_org "${org}" MEMBERS

   policy_skip_ui "${org}" 2FA

   ## PAID PLAN ONLY: org-level code-security-configuration API
   ## requires GHAS, and org-level rulesets require GitHub Team+.
   ## Both fail with HTTP 400/403 on Free. Re-enable the four
   ## commented-out calls together.
   # apply_code_security_config "${org}" || true
   log notice "skip: ${org}: code-security configuration - PAID PLAN ONLY (GHAS); see github-policy-data.bsh POLICY_CODE_SECURITY"

   # bypass_var="POLICY_RULESET_BYPASS_${kind^^}"
   # if check_variable_name "${bypass_var}"; then
   #    policy_upsert_org_ruleset "${org}" "${RULESET_NAME_BRANCH}" \
   #       "$(policy_branch_ruleset_body "${RULESET_NAME_BRANCH}" org "${!bypass_var}")" \
   #       || true
   # fi

   # policy_upsert_org_ruleset "${org}" "${RULESET_NAME_TAG}" \
   #    "$(policy_tag_ruleset_body "${RULESET_NAME_TAG}" org)" \
   #    || true
   log notice "skip: ${org}: org-level branch + tag rulesets - PAID PLAN ONLY (GitHub Team+); per-repo rulesets work on Free for public repos"
   policy_skip_ui "${org}" PAT

   log notice "skip: ${org}: ${POLICY_APPS_LABEL} - see ${POLICY_APPS_UI_URL_INSTALLATIONS//__ORG__/${org}} and ${POLICY_APPS_UI_URL_OAUTH//__ORG__/${org}}"

   ## SOURCE-only UI-flip notices for Dependabot + Code-scanning
   ## settings with no documented REST setter as of 2026-05; see
   ## agents/github-policy-canonical-vs-mirror.md "SOURCE-side
   ## UI-only operator flips". MIRROR has Dependabot off entirely
   ## (so grouping is moot) and runs CodeQL via the reusable
   ## codeql.yml workflow (advanced setup, ignores the default-setup
   ## query-suite recommendation).
   if [ "${kind}" = 'source' ]; then
      policy_skip_ui "${org}" GROUPED_SECURITY_UPDATES
      policy_skip_ui "${org}" CODE_SCANNING_QUERY_SUITE
   fi
}

## GET-and-print helper for audit_org_state.
## Args: $1 = label, $2 = API path, $3 = jq filter (default '.').
audit_get() {
   local label path jq_filter result status body extracted

   label="$1"
   path="$2"
   jq_filter="${3:-.}"

   result="$(ghorg_api GET "${path}")" || {
      log notice "  ${label}: <api error>"
      return 0
   }
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" != '200' ]; then
      log notice "  ${label}: HTTP ${status}"
      return 0
   fi
   body="$(ghorg_body_of "${result}")"
   extracted="$(printf '%s' "${body}" \
      | ghorg_jq_capped -r -- "${jq_filter}" 2>/dev/null \
      | tr -- '\n' ' ')"
   log notice "  ${label}: ${extracted:0:200}"
}

## Read-only inspection. Run before --apply to spot the change most
## likely to surprise: an Allowed-Actions tightening that breaks
## workflows pinned to unverified third-party actions.
audit_org_state() {
   local org result status body count names
   local actions_result actions_status actions_body
   local allowed_actions enabled_repos
   local member
   local installs install_line total no_secret
   local repos repo missing_count have_count
   local private_names private_count

   org="$1"

   log notice "=== audit: ${org} ==="

   audit_get 'fork-PR approval policy' \
      "/orgs/${org}/actions/permissions/fork-pr-contributor-approval" \
      '.approval_policy // "(unset)"'

   audit_get 'workflow GITHUB_TOKEN permissions' \
      "/orgs/${org}/actions/permissions/workflow" \
      '"default=" + (.default_workflow_permissions // "?") + ", can_approve_PRs=" + ((.can_approve_pull_request_reviews // false) | tostring)'

   ## /actions/permissions/selected-actions returns 409 when
   ## allowed_actions != "selected"; suppress the call in that case.
   allowed_actions=''
   actions_result="$(ghorg_api GET "/orgs/${org}/actions/permissions")" || true
   actions_status="$(ghorg_status_of "${actions_result}")"
   if [ "${actions_status}" = '200' ]; then
      actions_body="$(ghorg_body_of "${actions_result}")"
      ## Downstream comparison is exact-string against literals so a
      ## hostile-byte value falls into the N/A branch.
      allowed_actions="$(printf '%s' "${actions_body}" | ghorg_jq_capped --raw-output -- '.allowed_actions // "?"')"
      enabled_repos="$(printf '%s' "${actions_body}" | ghorg_jq_capped --raw-output -- '.enabled_repositories // "?"')"
      log notice "  actions allowed_actions: enabled_repos=${enabled_repos}, allowed_actions=${allowed_actions}"
   else
      log notice "  actions allowed_actions: HTTP ${actions_status}"
   fi

   if [ "${allowed_actions}" = 'selected' ]; then
      audit_get 'selected-actions allow-list' \
         "/orgs/${org}/actions/permissions/selected-actions" \
         '"github_owned=" + ((.github_owned_allowed // false) | tostring) + ", verified=" + ((.verified_allowed // false) | tostring) + ", patterns=" + ((.patterns_allowed // []) | length | tostring)'
   else
      log notice "  selected-actions allow-list: (N/A while allowed_actions=${allowed_actions:-?})"
   fi

   audit_get '2FA required for org members' \
      "/orgs/${org}" \
      '.two_factor_requirement_enabled // false'

   audit_get 'code-security defaults for new repos' \
      "/orgs/${org}" \
      '"secret=" + ((.secret_scanning_enabled_for_new_repositories // false) | tostring) + ", push_prot=" + ((.secret_scanning_push_protection_enabled_for_new_repositories // false) | tostring) + ", dep_alerts=" + ((.dependabot_alerts_enabled_for_new_repositories // false) | tostring) + ", dep_updates=" + ((.dependabot_security_updates_enabled_for_new_repositories // false) | tostring) + ", dep_graph=" + ((.dependency_graph_enabled_for_new_repositories // false) | tostring)'

   ## Members without 2FA - the list that would be removed if 2FA
   ## enforcement were enabled.
   log notice '  members lacking 2FA (would lose org access until they enroll):'
   result="$(ghorg_api GET "/orgs/${org}/members?filter=2fa_disabled&per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      count="$(printf '%s' "${body}" | ghorg_jq_capped -- 'length')"
      if [ "${count}" = '0' ]; then
         log notice '    (none)'
      else
         names="$(printf '%s' "${body}" | ghorg_jq_capped -r -- '.[].login')"
         while IFS= read -r member; do
            [ -z "${member}" ] && continue
            log notice "    - ${member}"
         done <<< "${names}"
      fi
   else
      log notice "    HTTP ${status} (need admin:org / Members read scope)"
   fi

   ## PAID PLAN ONLY. Org-level rulesets list (GET /orgs/{org}/rulesets)
   ## returns HTTP 403 with body "Upgrade to GitHub Team to enable this
   ## feature." on Free-plan orgs. The org-ai-assisted org is Free, so
   ## this block was dead noise on every audit. Apply uses repo-level
   ## rulesets (Free-compatible) via policy_upsert_repo_ruleset; this
   ## listing block is only useful if the plan is upgraded to Team+.
   ## Uncomment if/when the plan changes; restore the corresponding
   ## 'existing rulesets named ...' assertions in
   ## ci/tests/test_dm_audit.sh too.
   # result="$(ghorg_api GET "/orgs/${org}/rulesets?per_page=100")" || true
   # status="$(ghorg_status_of "${result}")"
   # if [ "${status}" = '200' ]; then
   #    ruleset_body="$(ghorg_body_of "${result}")"
   #    for ruleset_name in "${RULESET_NAME_BRANCH}" "${RULESET_NAME_TAG}"; do
   #       log notice "  existing rulesets named \"${ruleset_name}\":"
   #       ids="$(printf '%s' "${ruleset_body}" \
   #          | ghorg_jq_capped -r --arg name "${ruleset_name}" -- \
   #            '.[] | select(.name == $name) | .id' || true)"
   #       if [ -z "${ids}" ]; then
   #          log notice '    (none; --apply would create one)'
   #       else
   #          while IFS= read -r id; do
   #             [ -z "${id}" ] && continue
   #             log notice "    - id=${id}"
   #          done <<< "${ids}"
   #       fi
   #    done
   # else
   #    log notice "  rulesets list: HTTP ${status} (paid plan only / token may lack admin:org)"
   # fi

   ## GITHUB-APP-ONLY. Both endpoints under 'fine-grained PAT activity'
   ## are GitHub-App-only per docs.github.com:
   ##   GET /orgs/{org}/personal-access-token-requests
   ##   GET /orgs/{org}/personal-access-tokens
   ## "Only GitHub Apps can use this endpoint." Classic and fine-grained
   ## PATs are rejected regardless of scope - response is HTTP 404 with
   ## empty 'x-accepted-oauth-scopes' header. The PAT-restriction policy
   ## toggle in the org UI does NOT change this: it controls whether
   ## PATs may BE USED to access org resources, not whether REST clients
   ## can ENUMERATE such tokens. dm-github-org-policy is entirely
   ## PAT-authenticated via ghorg_api, so these calls can never return
   ## data here - block was dead noise on every audit.
   ## Uncomment if/when an App-token code path is added; restore the
   ## 'fine-grained PAT activity:' assertion in
   ## ci/tests/test_dm_audit.sh too.
   # log notice '  fine-grained PAT activity:'
   # result="$(ghorg_api GET "/orgs/${org}/personal-access-token-requests?per_page=1")" || true
   # status="$(ghorg_status_of "${result}")"
   # case "${status}" in
   #    200)
   #       pending="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
   #       log notice "    pending requests on first page: ${pending}"
   #       ;;
   #    404|403)
   #       log notice "    requests endpoint: HTTP ${status} (endpoint accepts GitHub App tokens only, not PATs)"
   #       ;;
   #    *)
   #       log notice "    requests endpoint: HTTP ${status}"
   #       ;;
   # esac
   # result="$(ghorg_api GET "/orgs/${org}/personal-access-tokens?per_page=1")" || true
   # status="$(ghorg_status_of "${result}")"
   # case "${status}" in
   #    200)
   #       approved="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
   #       log notice "    approved tokens on first page: ${approved}"
   #       ;;
   #    *)
   #       log notice "    approved tokens endpoint: HTTP ${status} (endpoint accepts GitHub App tokens only, not PATs)"
   #       ;;
   # esac

   log notice '  installed GitHub Apps:'
   result="$(ghorg_api GET "/orgs/${org}/installations?per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      installs="$(ghorg_body_of "${result}" \
         | ghorg_jq_capped -r -- '.installations[]? | "    - " + (.app_slug // "?") + " (id=" + ((.id // 0) | tostring) + ")"' \
         2>/dev/null || true)"
      if [ -z "${installs}" ]; then
         log notice '    (none)'
      else
         while IFS= read -r install_line; do
            [ -z "${install_line}" ] && continue
            log notice "${install_line}"
         done <<< "${installs}"
      fi
   else
      log notice "    HTTP ${status}"
   fi

   ## Org webhooks: any without a configured `secret` are a Critical
   ## risk per the OSSF Scorecard "Webhooks" check. The GitHub API
   ## never returns the secret value itself; presence is reflected as
   ## a non-empty placeholder in `.config.secret`. Absent or empty
   ## means no secret configured - the webhook accepts unauthenticated
   ## payloads.
   log notice '  org webhooks (Scorecard "Webhooks" check):'
   result="$(ghorg_api GET "/orgs/${org}/hooks?per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      total="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
      no_secret="$(ghorg_body_of "${result}" \
         | ghorg_jq_capped -- '[.[] | select(.config.secret == null or .config.secret == "")] | length' \
         2>/dev/null || printf '%s' '?')"
      if [ "${total}" = '0' ]; then
         log notice '    (none configured)'
      else
         log notice "    total=${total}, lacking secret=${no_secret}"
      fi
   else
      log notice "    HTTP ${status} (token may lack admin:org_hook scope)"
   fi

   ## Private (non-archived) repos. None should exist in the active
   ## layout. A private repo on Free loses secret scanning + push
   ## protection (those features need GHAS on private repos).
   log notice '  private repos (would lose secret-scan + push-protection on Free without GHAS):'
   result="$(ghorg_api GET "/orgs/${org}/repos?type=private&per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      ## ghorg_mock_dispatch strips queries before fixture lookup, so
      ## an explicit .private filter keeps real and mock paths
      ## consistent against a mixed-list fixture.
      private_names="$(printf '%s' "${body}" \
         | ghorg_jq_capped -r -- '.[] | select(.private == true and .archived == false) | .name')"
      private_count="$(printf '%s' "${private_names}" | grep --count -- . || true)"
      if [ "${private_count}" = '0' ]; then
         log notice '    (none)'
      else
         while IFS= read -r repo; do
            [ -z "${repo}" ] && continue
            log notice "    - ${org}/${repo}"
         done <<< "${private_names}"
      fi
   else
      log notice "    HTTP ${status} (token may lack repo scope to enumerate private repos)"
   fi

   ## OSSF Scorecard "Dependency-Update-Tool" looks for
   ## .github/dependabot.yml file presence specifically, so even with
   ## org-level Dependabot alerts enabled, repos missing the file
   ## still fail Scorecard.
   log notice '  dependabot.yml presence (Scorecard "Dependency-Update-Tool"):'
   repos="$(ghorg_list_repos "${org}" 'false' 'false' 'true' | sort --unique)"
   if [ -z "${repos}" ]; then
      log notice '    (no public non-archived repos)'
   else
      ## Per-repo presence is shown so legitimately-bare repos
      ## (no language deps to track - propagating dependabot.yml
      ## everywhere just to satisfy Scorecard would be noise) are
      ## visible alongside the ones that genuinely need it.
      missing_count=0
      have_count=0
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         result="$(ghorg_api GET "/repos/${org}/${repo}/contents/.github/dependabot.yml")" || true
         status="$(ghorg_status_of "${result}")"
         if [ "${status}" = '200' ]; then
            have_count=$(( have_count + 1 ))
            log notice "    yes: ${org}/${repo}"
         else
            missing_count=$(( missing_count + 1 ))
            log notice "    no:  ${org}/${repo}"
         fi
      done <<< "${repos}"
      log notice "    summary: have=${have_count}, missing=${missing_count}"
   fi
}

## Per-repo settings. Steps + the SOURCE-vs-MIRROR rationale are
## documented once in agents/github-policy-canonical-vs-mirror.md;
## the constants this function reads (POLICY_REPO_*, POLICY_RULESET_
## BYPASS_*, POLICY_RULESET_RULES_*) are defined in
## github-policy-data.bsh.
apply_repo_policy() {
   local owner repo kind bypass_var rules_var

   owner="$1"
   repo="$2"

   ghorg_validate_name "${owner}" user || return 1
   ghorg_validate_name "${repo}"  repo || return 1

   kind="$(org_kind "${owner}")" || return 1
   policy_apply_repo "${owner}" "${repo}" "REPO_${kind^^}" ENDPOINT

   # Order: G-035 / see github-policy-canonical-vs-mirror.md
   if [ "${kind}" = 'source' ]; then
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_ALERTS ENDPOINT
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_FIXES  ENDPOINT
   else
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_FIXES_OFF  ENDPOINT
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_ALERTS_OFF ENDPOINT
   fi

   policy_apply_repo "${owner}" "${repo}" REPO_PVR_OFF ENDPOINT

   bypass_var="POLICY_RULESET_BYPASS_${kind^^}"
   rules_var="POLICY_RULESET_RULES_${kind^^}"
   if check_variable_name "${bypass_var}" && check_variable_name "${rules_var}"; then
      policy_upsert_repo_ruleset "${owner}" "${repo}" "${RULESET_NAME_BRANCH}" \
         "$(policy_branch_ruleset_body "${RULESET_NAME_BRANCH}" repo "${!bypass_var}" "${!rules_var}")" \
         || true
      policy_upsert_repo_ruleset "${owner}" "${repo}" "${RULESET_NAME_TAG}" \
         "$(policy_tag_ruleset_body "${RULESET_NAME_TAG}" repo "${!bypass_var}" "${!rules_var}")" \
         || true
   fi

   ## SOURCE-only UI-flip notices for per-repo Dependabot settings
   ## with no documented REST setter as of 2026-05; see
   ## agents/github-policy-canonical-vs-mirror.md "SOURCE-side
   ## UI-only operator flips". MIRROR has Dependabot off entirely,
   ## so auto-triage rules and delegated-dismissal are moot there.
   if [ "${kind}" = 'source' ]; then
      policy_skip_ui_repo "${owner}" "${repo}" AUTO_TRIAGE_DISMISS_DEV
      policy_skip_ui_repo "${owner}" "${repo}" AUTO_TRIAGE_DISMISS_MALWARE
      policy_skip_ui_repo "${owner}" "${repo}" DELEGATED_DISMISSAL
   fi
}

main() {
   local org repos repo
   if [ "${mode}" = 'audit' ]; then
      for org in "${ORGS[@]}"; do
         audit_org_state "${org}"
      done
      return 0
   fi

   for org in "${ORGS[@]}"; do
      apply_org_policy "${org}"

      log notice "=== repo policy: ${org} ==="
      ## Public, non-archived repos (forks INCLUDED). The
      ## org-ai-assisted mirror holds 100% forks of upstream
      ## Kicksecure / Whonix repos; excluding forks would silently
      ## no-op the entire per-repo phase against that org.
      repos="$(ghorg_list_repos "${org}" 'false' 'false' 'true' | sort --unique)"
      if [ -z "${repos}" ]; then
         log notice "no repos matched in '${org}'"
         continue
      fi

      ## Sequential loop: a small org (~12 repos) is fast enough not
      ## to need backgrounding, and lets policy_warn_seen mutations
      ## from the lib propagate back without a tempfile / pid dance.
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         apply_repo_policy "${org}" "${repo}"
      done <<< "${repos}"
   done

   [ "${policy_warn_seen}" -eq 1 ] && return 1
   return 0
}

main
