#!/bin/bash

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

## AI-Assisted

## Shared functions for the github-org-* tools (clone/fork/push).
##
## Threat model + identifier/display-sink discipline is documented
## once in agents/security.md (R-140). This lib implements that
## contract via ghorg_validate_name (identifier sink) and the log()
## -> sanitize-string pipeline (display sink). Resource caps
## (GHORG_MAX_*) below bound damage from hostile pagination,
## redirect chains, oversized bodies, and oversized "numeric" ids.

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

# shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh
# 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

## API base URL. Override to point at a captured-replay server.
[ -v GHORG_API ] || GHORG_API='https://api.github.com'

## REST API version pin sent in the X-GitHub-Api-Version header.
## https://docs.github.com/en/rest/about-the-rest-api/api-versions
## https://docs.github.com/en/rest/about-the-rest-api/breaking-changes
[ -v GHORG_API_VERSION ] || GHORG_API_VERSION='2026-03-10'

## Per documented GitHub limits.
GHORG_MAX_REPO_NAME_LEN=100
GHORG_MAX_USER_LOGIN_LEN=39
## Filesystem-level ceiling for refs stored as files under .git/refs/;
## GitHub does not document a hard cap, but ref storage tops out here
## on any sane filesystem. Deeper validation is left to the downstream
## 'git check-ref-format' call - this is just the shallow defense-in-
## depth gate before constructing a fully-qualified refname.
GHORG_MAX_BRANCH_NAME_LEN=255

GHORG_MAX_DISPLAY_LEN=256

## Cap on bytes passed through log() (which pipes through
## sanitize-string with LOG_MAX_LEN). Without this, log() defaults to
## 'nolimit'.
[ -v LOG_MAX_LEN ] || LOG_MAX_LEN="${GHORG_MAX_DISPLAY_LEN}"

[ -v GHORG_MAX_RETRIES ] || GHORG_MAX_RETRIES='3'

## Backoff doubles per attempt: 30s, 60s, 120s ...
##
## Refuse a single sleep longer than this (Retry-After /
## X-RateLimit-Reset on a quiet hour boundary can suggest hours).
[ -v GHORG_MAX_BACKOFF_SECONDS ] || GHORG_MAX_BACKOFF_SECONDS='3600'

## Cap hostile pager loop.
[ -v GHORG_MAX_PAGES ] || GHORG_MAX_PAGES='100'

[ -v GHORG_MAX_REDIRS ] || GHORG_MAX_REDIRS='5'

## Max wall-clock seconds to wait for a freshly-POSTed fork to leave
## the empty-repo state. On timeout, wait_for_fork_ready warns and
## the next idempotent run picks up the configure.
[ -v GHORG_FORK_READY_TIMEOUT_SECONDS ] || GHORG_FORK_READY_TIMEOUT_SECONDS='300'

## Cap on numeric id length before URL interpolation. ^[0-9]+$ alone
## would accept arbitrary length.
GHORG_MAX_ID_LEN=20

declare -g -A ghorg_account_type_cache=()

ghorg_authenticated_user_cached=''

## ============================================================================

## Resolves a GitHub token from ${GITHUB_TOKEN} or ~/.config/github-token.
## The token file may be a symlink: it is canonicalized with `readlink -f`
## and every check (regular file, permissions 600 or 400, readable, single
## token line) is then applied to that real path, so stat and read operate
## on the same file and the symlink cannot be repointed between them.
ghorg_token() {
  local token_file mode token default_config_dir default_token_path

  xtrace_off
  trap xtrace_restore RETURN

  if [ -n "${GITHUB_TOKEN:-}" ]; then
    token="${GITHUB_TOKEN}"
  else
    default_config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}"
    default_token_path="${default_config_dir}/github-token"
    token_file="${GHORG_TOKEN_FILE:-${default_token_path}}"
    if [ -L "${token_file}" ]; then
      ## Canonicalize once, then run every check below on the real path so
      ## stat and read hit the same file (no swap-the-symlink TOCTOU).
      token_file="$(readlink -f -- "${token_file}" 2>/dev/null || true)"
      if [ -z "${token_file}" ]; then
        log error 'cannot resolve github-token symlink target'
        return 1
      fi
    fi
    if [ ! -e "${token_file}" ]; then
      log error "no GitHub token. Set GITHUB_TOKEN or write one line to '${token_file}' and set its permissions to 0600."
      return 1
    fi
    if [ ! -f "${token_file}" ]; then
      log error "'${token_file}' is not a regular file"
      return 1
    fi
    if [ ! -r "${token_file}" ]; then
      log error "'${token_file}' is not readable"
      return 1
    fi
    ## GNU stat only; we only support Debian stable.
    mode="$(stat -c '%a' -- "${token_file}" 2>/dev/null || true)"
    if [ -z "${mode}" ]; then
      log error "cannot stat '${token_file}'"
      return 1
    fi
    if [ "${mode}" != '600' ] && [ "${mode}" != '400' ]; then
      log error "'${token_file}' permissions are '${mode}', expected 600 or 400"
      return 1
    fi
    token="$(head --lines=1 -- "${token_file}" | tr -d '\r\n')"
  fi

  ## Verify token.
  [ "${#token}" -le 255 ] || { log error "'${token_file}' first line exceeds 255 bytes"; return 1; }
  ## stderr suppressed: check_is_alpha_numeric prints the value on
  ## failure, which would leak the secret.
  check_is_alpha_numeric token 2>/dev/null \
    || { log error "'${token_file}' contains disallowed characters"; return 1; }
  printf '%s' "${token}"
}

ghorg_require_deps() {
  local cmd

  ## No Git dependency, as none of the github-org-tools use Git.
  for cmd in curl jq; do
    has "${cmd}" || { log error "required command not found: '${cmd}'"; return 1; }
  done
}

# G-001: see agents/github-org-tools.md
## Defense-in-depth validator. 'kind' selects the length cap and the
## character allowlist:
##   repo / user   ^[A-Za-z0-9._-]+$         (no slashes)
##   ref           ^[A-Za-z0-9._/-]+$        (slashes allowed for
##                                            'feature/foo' branches)
##
## ref-format details beyond the shallow gate (no '@{', no trailing
## '.lock', no consecutive '/', etc.) are left to the caller's
## downstream 'git check-ref-format "refs/heads/${name}"' check; this
## function only ensures the value is safe to interpolate into argv /
## URL paths and is shorter than the relevant cap.
ghorg_validate_name() {
  local name kind max_len allowed_re

  name="$1"
  kind="${2:-repo}"

  case "${kind}" in
    repo)
      max_len="${GHORG_MAX_REPO_NAME_LEN}"
      allowed_re='^[A-Za-z0-9._-]+$'
      ;;
    user)
      max_len="${GHORG_MAX_USER_LOGIN_LEN}"
      allowed_re='^[A-Za-z0-9._-]+$'
      ;;
    ref)
      max_len="${GHORG_MAX_BRANCH_NAME_LEN}"
      allowed_re='^[A-Za-z0-9._/-]+$'
      ;;
    *)
      log error "reject: unknown 'kind' '${kind}' passed to ghorg_validate_name"
      return 1
      ;;
  esac

  if [ -z "${name}" ]; then
    log error "reject: empty '${kind}' name"
    return 1
  fi
  if [ "${#name}" -gt "${max_len}" ]; then
    log error "reject: '${kind}' name exceeds '${max_len}' chars: '${name}'"
    return 1
  fi
  if ! [[ "${name}" =~ ${allowed_re} ]]; then
    log error "reject: '${kind}' name has disallowed chars: '${name}'"
    return 1
  fi
  case "${name}" in
    '.'|'..'|'.git')
      log error "reject: reserved '${kind}' name: '${name}'"
      return 1
      ;;
    '-'*|'/'*)
      log error "reject: '${kind}' name starts with disallowed char: '${name}'"
      return 1
      ;;
    *'..'*|*'//'*)
      log error "reject: '${kind}' name contains \"..\" or \"//\": '${name}'"
      return 1
      ;;
  esac
  return 0
}

## Backoff in seconds for retry attempt (0-indexed): 30s, 60s, 120s,
## ... bounded by GHORG_MAX_BACKOFF_SECONDS. Floor for when no
## Retry-After or X-RateLimit-Reset is parseable from a 429 /
## 403-rate-limit response.
ghorg_compute_backoff_seconds() {
  local attempt seconds

  attempt="${1:-0}"
  ## R-141: validate before arithmetic context.
  is_whole_number "${attempt}" || return 1
  ## 1 << 62 is the largest positive number possible with Bash
  ## bit-shifting of 1. 1 << 63 is a "large" negative number, 1 << 64
  ## is 1.
  [ "${attempt}" -gt 62 ] && attempt='62'
  seconds=$(( 30 * (1 << attempt) ))

  if [ "${seconds}" -gt "${GHORG_MAX_BACKOFF_SECONDS}" ]; then
    seconds="${GHORG_MAX_BACKOFF_SECONDS}"
  fi
  printf '%s' "${seconds}"
}

## Parse Retry-After or X-RateLimit-Reset out of a header dump file.
## On success, echo the wait in seconds and return 0. If neither
## header is parseable (or the parsed reset already elapsed), echo
## nothing and return non-zero so the caller can fall back to
## exponential backoff via 'if ! wait="$(ghorg_parse_rate_limit_wait
## ...)"; then wait="$(ghorg_compute_backoff_seconds ...)"; fi'.
## Retry-After is absolute seconds; X-RateLimit-Reset is a unix
## timestamp from which 'now' is subtracted.
##
## grep --ignore-case + sed rather than awk's IGNORECASE - the latter
## is a gawk extension that mawk (default in many Debian-based CI
## images) does not honor.
ghorg_parse_rate_limit_wait() {
  local hdr_file retry_after reset now wait

  hdr_file="$1"

  [ -r "${hdr_file}" ] || return 1

  ## '|| true' is necessary as a header may be missing.
  retry_after="$(grep --ignore-case -- '^retry-after:' "${hdr_file}" 2>/dev/null \
    | head --lines=1 \
    | sed -- 's/^[^:]*:[[:space:]]*//' \
    | tr -d -- '[:cntrl:]' || true)"
  if [[ "${retry_after}" =~ ^[0-9]+$ ]]; then
    printf '%s' "${retry_after}"
    return 0
  fi
  reset="$(grep --ignore-case -- '^x-ratelimit-reset:' "${hdr_file}" 2>/dev/null \
    | head --lines=1 \
    | sed -- 's/^[^:]*:[[:space:]]*//' \
    | tr -d -- '[:cntrl:]' || true)"
  if [[ "${reset}" =~ ^[0-9]+$ ]]; then
    now="$(date -u +%s)"
    wait=$(( reset - now + 5 ))   ## small buffer so we don't wake too early
    if [ "${wait}" -gt 0 ]; then
      printf '%s' "${wait}"
      return 0
    fi
  fi
  return 1
}

## Mock-mode dispatcher. When GHORG_MOCK=true, requests are served from
## fixture files in ${GHORG_MOCK_DIR}. Fixture filenames encode method
## and path:
##
##   GET /orgs/Kicksecure/repos          -> GET_orgs_Kicksecure_repos
##   POST /orgs/Whonix/forks             -> POST_orgs_Whonix_forks
##
## Slashes become underscores. Query strings are stripped. The fixture
## contents must end with an HTTP_STATUS:NNN line.
##
## Missing fixture maps to "HTTP 599 mock not configured" so tests fail
## loudly rather than appearing to succeed against unset state.
ghorg_mock_dispatch() {
  local method path key clean_path fixture

  method="$1"
  path="$2"
  clean_path="${path%%\?*}"
  key="${method}${clean_path//\//_}"
  fixture="${GHORG_MOCK_DIR}/${key}"

  if [ -f "${fixture}" ] && [ -r "${fixture}" ]; then
    cat -- "${fixture}"
    return 0
  fi
  ## Exception to the project-wide "printf '%s\n' ..." rule: emitting
  ## a JSON literal full of double-quotes reads cleaner this way.
  printf '{"mock":"missing","method":"%s","path":"%s","key":"%s"}\n' \
    "${method}" "${clean_path}" "${key}"
  printf '%s\n' 'HTTP_STATUS:599'
}

## Authenticated GitHub REST call with retry on transient failures.
## Prints response body on stdout, then a final HTTP_STATUS:NNN line.
##
## Retry policy: on HTTP 429, HTTP 403 with body containing
## "rate limit", or HTTP 5xx, sleep using Retry-After /
## X-RateLimit-Reset (or exponential backoff), retry up to
## GHORG_MAX_RETRIES times.
##
## Args: METHOD PATH [BODY_JSON]
##
## Without a token, the request goes anonymously (60/hr per IP).

ghorg_api_cleanup() {
  ## Single RETURN trap so every exit path restores xtrace and unlinks
  ## the header file; hdr_file may be unset on early-return paths.
  # shellcheck disable=SC2154
  [ -n "${hdr_file:-}" ] && safe-rm --force -- "${hdr_file}"
  xtrace_restore
}

ghorg_api() {
  local method path body token hdr_file default_token_path
  local attempt max status status_line result wait attempt_next
  local rate_remaining
  local -a args

  method="$1"
  path="$2"
  body="${3:-}"

  xtrace_off
  hdr_file=''
  trap ghorg_api_cleanup RETURN

  if [ "${GHORG_MOCK:-false}" = 'true' ]; then
    ghorg_mock_dispatch "${method}" "${path}"
    return 0
  fi

  is_whole_number "${GHORG_MAX_RETRIES}" || { log error 'GHORG_MAX_RETRIES must be a whole number'; return 1; }
  is_whole_number "${GHORG_MAX_BACKOFF_SECONDS}" || { log error 'GHORG_MAX_BACKOFF_SECONDS must be a whole number'; return 1; }

  ## Fail closed when auth was explicitly configured but invalid;
  ## silently downgrading to anonymous would mask a bad token and
  ## hit the 60/hr unauth rate limit unexpectedly. A non-empty
  ## GHORG_TOKEN_FILE counts as "configured" even if the file is
  ## missing - the operator clearly chose that path.
  default_token_path="${XDG_CONFIG_HOME:-${HOME}/.config}/github-token"
  if [ -n "${GITHUB_TOKEN:-}" ] \
    || [ -n "${GHORG_TOKEN_FILE:-}" ] \
    || [ -e "${default_token_path}" ]; then
    ## Auth explicitly configured -> fail closed, and let ghorg_token's
    ## specific reason (bad permissions, unresolvable symlink, disallowed
    ## characters, ...) reach the operator instead of suppressing it behind
    ## a generic 'configured but invalid' message.
    token="$(ghorg_token)" || return 1
  else
    ## Nothing configured -> request anonymously (60/hr per IP).
    token=''
  fi

  hdr_file="$(mktemp)"

  args=(
    --silent
    --show-error
    --location
    --max-redirs "${GHORG_MAX_REDIRS}"
    --proto '=https'
    --proto-redir '=https'
    --connect-timeout 30
    --max-time 120
    --max-filesize 10485760
    --request "${method}"
    --header 'Accept: application/vnd.github+json'
    --header "X-GitHub-Api-Version: ${GHORG_API_VERSION}"
    --dump-header "${hdr_file}"
    --write-out '\nHTTP_STATUS:%{http_code}'
  )
  if [ -n "${body}" ]; then
    args+=( --header 'Content-Type: application/json' --data "${body}" )
  fi

  ## attempt starts at 0; ghorg_compute_backoff_seconds returns
  ## 30 * (1 << attempt) so the first retry waits 30s, then 60s,
  ## 120s, ... up to GHORG_MAX_BACKOFF_SECONDS. A 0-second sleep
  ## before the first retry would defeat the rate-limit-respecting
  ## purpose of the loop.
  attempt=0
  max="${GHORG_MAX_RETRIES:-3}"

  while true ; do
    true > "${hdr_file}"
    if [ -n "${token}" ]; then
      ## Token via stdin so it doesn't appear in 'ps aux'.
      result="$(printf '%s\n' "header = \"Authorization: Bearer ${token}\"" \
        | curl "${args[@]}" --config - "${GHORG_API}${path}" || true)"
    else
      result="$(curl "${args[@]}" "${GHORG_API}${path}" || true)"
    fi
    ## --write-out '\nHTTP_STATUS:%{http_code}' appends the sentinel as
    ## the last line; require the prefix so a body whose final line
    ## happens to look numeric cannot impersonate the status.
    status_line="$(printf '%s\n' "${result}" | tail --lines=1)"
    case "${status_line}" in
      HTTP_STATUS:*)
        status="${status_line#HTTP_STATUS:}"
        ;;
      *)
        log warn "ghorg_api: malformed curl output (no HTTP_STATUS sentinel) for '${method}' '${path}'"
        break
        ;;
    esac

    case "${status}" in
      429)
        true "INFO: ghorg_api: HTTP 429 (Too Many Requests) - will retry"
        ;;
      403)
        ## Per GitHub's REST rate-limits documentation: a rate-limited
        ## 403 carries 'x-ratelimit-remaining: 0' for the primary rate
        ## limit, or a 'retry-after' header for the secondary rate
        ## limit. Both signals are part of the documented contract;
        ## the body wording ('rate limit' / 'secondary rate') is not.
        rate_remaining="$(grep --ignore-case -- '^x-ratelimit-remaining:' "${hdr_file}" 2>/dev/null \
          | head --lines=1 \
          | sed -- 's/^[^:]*:[[:space:]]*//' \
          | tr -d -- '[:cntrl:]' || true)"
        if [ "${rate_remaining}" = '0' ] \
          || grep --quiet --ignore-case -- '^retry-after:' "${hdr_file}" 2>/dev/null; then
          true "INFO: ghorg_api: HTTP 403 rate-limit (x-ratelimit-remaining='${rate_remaining}') - will retry"
        else
          break
        fi
        ;;
      500|502|503|504)
        true "INFO: ghorg_api: HTTP ${status} (transient server error) - will retry"
        ;;
      *)
        break
        ;;
    esac

    if [ "${attempt}" -ge "${max}" ]; then
      log warn "ghorg_api: gave up after '${max}' retries on HTTP '${status}' for '${method}' '${path}'"
      break
    fi

    if ! wait="$(ghorg_parse_rate_limit_wait "${hdr_file}")"; then
      wait="$(ghorg_compute_backoff_seconds "${attempt}")"
    fi
    if [ "${wait}" -gt "${GHORG_MAX_BACKOFF_SECONDS}" ]; then
      log warn "ghorg_api: server requested '${wait}'s wait, exceeds GHORG_MAX_BACKOFF_SECONDS='${GHORG_MAX_BACKOFF_SECONDS}'"
      break
    fi
    attempt_next=$(( attempt + 1 ))
    log info "ghorg_api: HTTP '${status}' for '${method}' '${path}'; sleeping '${wait}'s before retry '${attempt_next}'/'${max}'"
    sleep -- "${wait}"
    attempt=$(( attempt + 1 ))
  done

  printf '%s\n' "${result}"
}

ghorg_status_of() {
  ## ghorg_api emits the body followed by '\nHTTP_STATUS:NNN'. If the
  ## sentinel is missing (curl never wrote the trailing line, or the
  ## caller passed the body only), echo nothing so the consumer's
  ## "${status}" check fails-closed rather than silently treating the
  ## last body line as a status.
  local last
  last="$(printf '%s\n' "$1" | tail --lines=1)"
  case "${last}" in
    HTTP_STATUS:*) printf '%s\n' "${last#HTTP_STATUS:}" ;;
    *)             ;;
  esac
}

ghorg_body_of() {
  printf '%s\n' "$1" | sed -- '$d'
}

## ============================================================================
## jq invocation wrappers (parser-CVE defense in depth)
## ============================================================================
##
## All jq calls in the github-org-* / dm-github-* tools go through
## these to apply additional hardening. See agents/security.md
## "Parser-level trust assumptions".
##
##   ghorg_jq         input is trusted. Wall-clock timeout only.
##   ghorg_jq_capped  input may be hostile. Caps stdin to
##                    GHORG_JQ_MAX_BYTES before jq sees them, then
##                    applies the same timeout.
##
## --kill-after is mandatory: if a parser bug leaves jq's signal
## handler blocked, the SIGTERM at the soft deadline is silently
## ignored. --kill-after schedules a follow-up SIGKILL.
##
## R-062: timeout does NOT support '--' between DURATION and COMMAND.

: "${GHORG_JQ_MAX_BYTES:=4194304}"

## 2s soft / 1s hard timeout.
##
## Empirical: jq processes a 10 MB JSON list (100x larger than any
## realistic GitHub '?per_page=100' response) in ~100 ms; hardcoded
## POLICY body parses in ~3 ms. 2s gives 20x margin over the heaviest
## realistic load, tight enough that a parser-bug infinite loop is
## bounded to a few seconds.
: "${GHORG_JQ_TIMEOUT_SOFT:=2}"
: "${GHORG_JQ_TIMEOUT_HARD:=1}"

ghorg_jq() {
  timeout --kill-after="${GHORG_JQ_TIMEOUT_HARD}" \
    "${GHORG_JQ_TIMEOUT_SOFT}" jq "$@"
}

ghorg_jq_capped() {
  head -c "${GHORG_JQ_MAX_BYTES}" \
    | timeout --kill-after="${GHORG_JQ_TIMEOUT_HARD}" \
      "${GHORG_JQ_TIMEOUT_SOFT}" jq "$@"
}

## Probes whether $1 is a User or Organization. Cached in
## ghorg_account_type_cache. Echoes "User" or "Organization".
ghorg_account_type() {
  local owner result status body type

  owner="$1"

  ghorg_validate_name "${owner}" user || return 1
  ## Passing '${owner}' as an index is safe here, since associative
  ## arrays are not vulnerable to index command injection the way
  ## normal arrays are.
  if [ -n "${ghorg_account_type_cache[${owner}]:-}" ]; then
    printf '%s' "${ghorg_account_type_cache[${owner}]}"
    return 0
  fi
  result="$(ghorg_api GET "/users/${owner}")" || return 1
  status="$(ghorg_status_of "${result}")"
  body="$(ghorg_body_of "${result}")"
  if [ "${status}" != '200' ]; then
    log error "account type lookup for '${owner}': HTTP '${status}': ${body:0:512}"
    return 1
  fi
  type="$(printf '%s' "${body}" | ghorg_jq_capped --raw-output \
    -- '.type')"
  case "${type}" in
    User|Organization) ;;
    *)
      log error "unexpected account type for '${owner}': '${type}'"
      return 1
      ;;
  esac
  ghorg_account_type_cache["${owner}"]="${type}"
  printf '%s' "${type}"
}

## Lists repos belonging to an owner with optional filters. Endpoint
## is selected on account type (User vs Organization); the wrong one
## returns 404. Pagination is exhaustive.
##
## Args (may be empty strings to skip):
##   $1 owner
##   $2 include_private  ('true' = include, 'false' = exclude)
##   $3 include_archived ('true' = include, 'false' = exclude)
##   $4 include_forks    ('true' = include, 'false' = exclude)
##
## Output: one repo name per line.
ghorg_list_repos() {
  local owner inc_private inc_archived inc_forks
  local account_type api_base auth_user jq_filter
  local page result body status count name names_text
  local -a jq_clauses

  owner="$1"
  inc_private="$2"
  inc_archived="$3"
  inc_forks="$4"
  account_type="$(ghorg_account_type "${owner}")" || return 1

  case "${account_type}" in
    Organization)
      api_base="/orgs/${owner}/repos?per_page=100&type=all"
      ;;
    User)
      ## /users/{u}/repos returns ONLY public repos. To enumerate
      ## private repos owned by a user, GitHub requires /user/repos
      ## (the authenticated user's repos), which only works if the
      ## listed owner equals the authenticated user. Refuse rather
      ## than silently miss private forks - that would break fork
      ## idempotence.
      if [ "${inc_private}" = 'true' ]; then
        auth_user="$(ghorg_authenticated_user)" || return 1
        if [ "${owner}" != "${auth_user}" ]; then
          log error "cannot list private repos of user '${owner}'; auth token belongs to '${auth_user}'"
          return 1
        fi
        api_base="/user/repos?per_page=100&affiliation=owner"
      else
        api_base="/users/${owner}/repos?per_page=100"
      fi
      ;;
  esac

  jq_clauses=( '.[]' )
  if [ "${inc_private}" != 'true' ]; then
    jq_clauses+=( 'select(.private == false)' )
  fi
  if [ "${inc_archived}" != 'true' ]; then
    jq_clauses+=( 'select(.archived == false)' )
  fi
  if [ "${inc_forks}" != 'true' ]; then
    jq_clauses+=( 'select(.fork == false)' )
  fi
  jq_clauses+=( '.name' )
  jq_filter="$(IFS='|'; printf '%s' "${jq_clauses[*]}")"
  jq_filter="${jq_filter//|/ | }"

  page=1
  while true ; do
    if [ "${page}" -gt "${GHORG_MAX_PAGES}" ]; then
      log warn "ghorg_list_repos: hit pagination cap GHORG_MAX_PAGES='${GHORG_MAX_PAGES}' for '${owner}'; possible runaway pager"
      break
    fi
    ## All possible values of "${api_base}" have a query string
    ## already, thus there is no need to check if "?page=" needs to be
    ## used here instead.
    result="$(ghorg_api GET "${api_base}&page=${page}")" || return 1
    status="$(ghorg_status_of "${result}")"
    body="$(ghorg_body_of "${result}")"
    if [ "${status}" != '200' ]; then
      log error "list repos for '${owner}': HTTP '${status}'"
      return 1
    fi
    count="$(printf '%s' "${body}" | ghorg_jq_capped -- 'length')"
    [ "${count}" = '0' ] && break

    ## Capture into a string then iterate; "while ... < <(jq)" would
    ## hide a jq failure from errexit + pipefail.
    names_text="$(printf '%s' "${body}" | ghorg_jq_capped -r -- "${jq_filter}")"

    while IFS= read -r name; do
      [ -z "${name}" ] && continue
      if ghorg_validate_name "${name}" repo; then
        printf '%s\n' "${name}"
      fi
    done <<< "${names_text}"

    [ "${count}" -lt 100 ] && break
    page=$(( page + 1 ))
  done
}

## Applies an include/exclude regex pair to a stream of names on
## stdin. Order: include first, then exclude. Empty pattern = no
## filter on that side.
##
## Args: $1 include regex (POSIX ERE) or '', $2 exclude regex or ''
ghorg_filter_names() {
  local include_re exclude_re

  include_re="$1"
  exclude_re="$2"

  if [ -n "${include_re}" ]; then
    grep --extended-regexp -- "${include_re}" || true
  else
    cat
  fi | {
    if [ -n "${exclude_re}" ]; then
      grep --invert-match --extended-regexp -- "${exclude_re}" || true
    else
      cat
    fi
  }
}

## Probes whether a named repo exists. Distinguishes "definitively
## absent" (404) from "lookup failed for some other reason" (auth,
## rate limit, network) so callers can handle the latter as a hard
## error rather than misreport "missing".
##
## Return codes:
##   0 - repo exists (HTTP 200)
##   1 - repo does not exist (HTTP 404)
##   2 - lookup failed for any other reason
ghorg_repo_lookup() {
  local owner repo result status

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

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

  result="$(ghorg_api GET "/repos/${owner}/${repo}")" || return 2
  status="$(ghorg_status_of "${result}")"

  case "${status}" in
    200)
      return 0
      ;;
    404)
      return 1
      ;;
    *)
      log error "repo lookup '${owner}/${repo}': HTTP '${status}'"
      return 2
      ;;
  esac
}

## Returns parent.full_name of a repo, or empty if not a fork. Used
## purely as a diagnostic aid to detect cross-source name collisions:
## if assisted-by-ai already has a `foo` that's a fork of Whonix/foo,
## the user should be warned when attempting to mirror Kicksecure/foo.
##
## Args: $1 owner, $2 repo. Stdout: parent full_name or empty.
ghorg_repo_parent() {
  local owner repo result status body parent p_owner p_repo

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

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

  result="$(ghorg_api GET "/repos/${owner}/${repo}")" || return 1
  status="$(ghorg_status_of "${result}")"

  if [ "${status}" != '200' ]; then
    log warn "parent lookup for '${owner}/${repo}': HTTP '${status}'"
    return 1
  fi
  body="$(ghorg_body_of "${result}")"
  parent="$(printf '%s' "${body}" | ghorg_jq_capped -r -- '.parent.full_name // empty')"
  ## Validate owner and repo separately.
  if [ -n "${parent}" ]; then
    p_owner="${parent%%/*}"
    p_repo="${parent#*/}"
    if ! ghorg_validate_name "${p_owner}" user || \
      ! ghorg_validate_name "${p_repo}" repo; then
      return 1
    fi
  fi
  printf '%s' "${parent}"
}

## Returns the login of the user whose token is being used. Cached.
## Required before any operation whose semantics depend on the authed
## user matching a target user (forks into a User account, etc.).
ghorg_authenticated_user() {
  local result status body login

  if [ -n "${ghorg_authenticated_user_cached}" ]; then
    printf '%s' "${ghorg_authenticated_user_cached}"
    return 0
  fi

  result="$(ghorg_api GET "/user")" || return 1
  status="$(ghorg_status_of "${result}")"
  if [ "${status}" != '200' ]; then
    log error "cannot read authenticated user: HTTP '${status}'"
    return 1
  fi
  body="$(ghorg_body_of "${result}")"
  login="$(printf '%s' "${body}" | ghorg_jq_capped -r -- '.login')"
  ## NOTE: Technically, "null" is a valid GitHub username, and there
  ## is actually someone on GitHub with that name. This is therefore
  ## slightly incorrect. Not using '-r' with 'jq' would fix this, but
  ## it's probably not worth doing this for now.
  if [ -z "${login}" ] || [ "${login}" = 'null' ]; then
    log error 'authenticated user response missing .login'
    return 1
  fi
  ghorg_validate_name "${login}" user || return 1
  ghorg_authenticated_user_cached="${login}"
  printf '%s' "${login}"
}
