#!/bin/bash # grant-reviewer.sh - Grant a reviewer read + review access to an org-owned # Gitea repository via an org team (default: fleet-reviewers). # # Usage: grant-reviewer.sh -u [-r /] [-t ] # # The team carries `permission: read` with per-unit overrides # {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can # read code and write issues/PR reviews, but cannot push. The grant is # idempotent — the team is looked up before it is created, and member/repo # additions are PUTs. # # KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL. # Gitea computes a review's `official` flag at SUBMISSION time, from write # permission on the repo or from membership in the protected branch's # approvals whitelist (disabled by default). A team granted through this # script has read permission on code, so under branch protection with # required_approvals the reviewer's approval shows but does NOT count toward # the required total — the merge still fails with "not enough approvals". # Enabling the approvals whitelist and adding this team to it is review # policy (who counts as an official approver), an operator decision made in # the repo's branch-protection settings, deliberately NOT automated here. # Because `official` is fixed at submission, whitelisting after the fact # requires the review to be re-submitted before it counts. # # Platform: Gitea only. On a GitHub-remoted repo this script refuses to run — # GitHub review access is granted through collaborator/team facilities that # have no equivalent to Gitea's org-team unit map. # # Identity: the acting credential resolves exactly as in issue-comment.sh — # GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the # remote host (fail closed, never downgrade to the host default identity); # otherwise the per-seat identity ladder in detect-platform.sh applies # (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token, # fail-loud on fleet hosts). Managing org teams requires org owner/admin: # an HTTP 403 from any step is reported as "org admin required on ", # never as a silent partial grant. # # Verification is fail-closed: after the member and repo PUTs, the script # GETs the single resources back (GET /teams/{id}/members/{user} and # GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless # both confirm the grant. A PUT that returns success without persisting # (the #865 defect class: an exit code is not evidence of a durable write) # therefore fails the run instead of reporting a grant that does not exist. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" usage() { echo "Usage: grant-reviewer.sh -u [-r /] [-t ]" echo "" echo "Options:" echo " -u, --user Gitea username to grant reviewer access (required)" echo " -r, --repo Target repository as /; defaults to the" echo " current repository's origin. The owner must be an" echo " organization." echo " -t, --team Org team to use/create (default: fleet-reviewers)" echo " -h, --help Show this help" echo "" echo "Environment:" echo " GITEA_LOGIN Override the acting identity with a named tea login" echo " (must resolve for the remote host; fails closed)." echo "" echo "Grants: code read + issues/pulls write via an org team. Gitea only." echo "" echo "LIMITATION: under branch protection with required approvals, reviews" echo "from a read-permission team are official=false and do not count" echo "toward the required total. Making them count means enabling the" echo "protected branch's approvals whitelist and adding the team — an" echo "operator review-policy decision this script does not automate. The" echo "official flag is computed at review submission, so a review made" echo "before whitelisting must be re-submitted afterwards." } REVIEWER="" REPO_OVERRIDE="" TEAM="fleet-reviewers" while [[ $# -gt 0 ]]; do case $1 in -u|--user) REVIEWER="$2" shift 2 ;; -r|--repo) REPO_OVERRIDE="$2" shift 2 ;; -t|--team) TEAM="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 exit 1 ;; esac done if [[ -z "$REVIEWER" ]]; then echo "Error: reviewer username is required (-u)" >&2 exit 1 fi # Gitea usernames and team names are AlphaDashDot. Validating here keeps the # values safe to interpolate into API paths without URL-encoding. NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then echo "Error: invalid reviewer username '$REVIEWER'" >&2 exit 1 fi if ! [[ "$TEAM" =~ $NAME_RE ]]; then echo "Error: invalid team name '$TEAM'" >&2 exit 1 fi if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then echo "Error: -r expects /, got '$REPO_OVERRIDE'" >&2 exit 1 fi detect_platform >/dev/null if [[ "$PLATFORM" != "gitea" ]]; then echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2 echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2 exit 1 fi HOST=$(get_remote_host) || { echo "Error: could not resolve the remote host from origin" >&2 exit 1 } # Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity # ladder. Same ordering contract as issue-comment.sh — an explicit override is # never silently downgraded to the host default identity. if [[ -n "${GITEA_LOGIN:-}" ]]; then GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || { echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2 exit 1 } else GITEA_API_TOKEN=$(get_gitea_token "$HOST") || { echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=, or set GITEA_LOGIN= to use a named tea credential." >&2 exit 1 } fi CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || { echo "Error: configured Gitea URL not found for host '$HOST'" >&2 exit 1 } GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1" if [[ -n "$REPO_OVERRIDE" ]]; then REPO_SLUG="$REPO_OVERRIDE" else REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || { echo "Error: could not resolve / from origin; pass -r /" >&2 exit 1 } fi ORG="${REPO_SLUG%%/*}" REPO_NAME="${REPO_SLUG#*/}" RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX") AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || { rm -f "$RESPONSE_FILE" echo "Error: could not stage Gitea credential for reviewer grant" >&2 exit 1 } trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT # gitea_api [json-payload] # Runs one API call with the staged credential (token never in argv). Sets # GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure # and HTTP 403 are terminal here: 403 on ANY step means the acting identity # cannot manage org teams, and the run must stop rather than continue into a # partial grant. gitea_api() { local step="$1" method="$2" path="$3" payload="${4:-}" local -a payload_args=() if [[ -n "$payload" ]]; then payload_args=(-H 'Content-Type: application/json' -d "$payload") fi if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \ -X "$method" \ --config "$AUTH_CONFIG" \ "${payload_args[@]}" \ "$GITEA_API_ROOT$path"); then echo "Error: Gitea transport failed during $step" >&2 return 1 fi if [[ "$GITEA_API_STATUS" == "403" ]]; then echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2 return 1 fi return 0 } # json_field — print a top-level scalar field or fail. json_field() { python3 - "$1" "$2" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as response: data = json.load(response) value = data.get(sys.argv[2]) if isinstance(data, dict) else None if value is None or isinstance(value, (dict, list, bool)): raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}") except (OSError, json.JSONDecodeError, ValueError) as error: print(f"Error: unusable Gitea response: {error}", file=sys.stderr) raise SystemExit(1) print(value) PY } # 1. The owner must be an organization: teams are an org facility, and a # user-owned repo would fail later with a misleading team error. gitea_api "organization check" GET "/orgs/$ORG" if [[ "$GITEA_API_STATUS" == "404" ]]; then echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2 exit 1 fi if [[ "$GITEA_API_STATUS" != "200" ]]; then echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2 exit 1 fi # 2. Idempotent team resolution: exact-name lookup first, create only on miss. # The search endpoint substring-matches, so the exact-name filter is done # on the response, not trusted to the query. gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM" if [[ "$GITEA_API_STATUS" != "200" ]]; then echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2 exit 1 fi TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY' import json import os import sys wanted = os.environ["TEAM_NAME"] try: with open(sys.argv[1], encoding="utf-8") as response: result = json.load(response) teams = result.get("data") if isinstance(result, dict) else None if not isinstance(teams, list): raise ValueError("team search response carried no data list") except (OSError, json.JSONDecodeError, ValueError) as error: print(f"Error: unusable team search response: {error}", file=sys.stderr) raise SystemExit(1) for team in teams: if isinstance(team, dict) and team.get("name") == wanted: team_id = team.get("id") if not isinstance(team_id, int) or team_id <= 0: print("Error: matched team carried no positive id", file=sys.stderr) raise SystemExit(1) print(team_id) raise SystemExit(0) print("") PY ) if [[ -z "$TEAM_ID" ]]; then CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c ' import json import os print(json.dumps({ "name": os.environ["TEAM_NAME"], "description": "review seats: code read + issues/pulls write", "permission": "read", "includes_all_repositories": False, "can_create_org_repo": False, "units_map": { "repo.code": "read", "repo.issues": "write", "repo.pulls": "write", }, })) ') gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD" if [[ "$GITEA_API_STATUS" != "201" ]]; then echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2 exit 1 fi TEAM_ID=$(json_field "$RESPONSE_FILE" id) || { echo "Error: team create returned no usable team id" >&2 exit 1 } echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'" else echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'" fi # 3. Membership and repo attachment — both PUTs, both idempotent in Gitea. gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER" if [[ "$GITEA_API_STATUS" != "204" ]]; then echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2 exit 1 fi gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME" if [[ "$GITEA_API_STATUS" != "204" ]]; then echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2 exit 1 fi # 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the # grant persisted. GET the single resources back and require both. gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER" if [[ "$GITEA_API_STATUS" != "200" ]]; then echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2 exit 1 fi READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1 if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2 exit 1 fi gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME" if [[ "$GITEA_API_STATUS" != "200" ]]; then echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2 exit 1 fi READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1 if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2 exit 1 fi echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back" echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."