MOSAIC_GIT_IDENTITY=fargo produced objects attributed to mos-dt-0: every write wrapper resolved its acting principal from tea's login list, which enumerates whatever logins the host happens to hold and knows nothing about which seat is calling. The identity-aware code was present and correct but unreachable on the happy path — it sat on arms that only ran when tea failed. One shared resolver, not twenty patches: resolve_gitea_principal() in detect-platform.sh implements the precedence (explicit --login beats MOSAIC_GIT_IDENTITY / worktree git config mosaic.gitIdentity; the tea login list is the LAST resort), fails loud (nonzero, naming the identity or login and the expected slot path) when the requested principal has no credential, and never prints a token value. gitea_identity_token_slot() is the single source of truth for the slot layout, shared with get_gitea_token, so resolver and token resolution cannot disagree. Call-site conversions (the proving five): pr-review.sh (principal resolved once for every action; the comment action now honors --login), issue-comment.sh, pr-create.sh (identity mode reaches the REST API on the HAPPY path — tea is never consulted, so the login list cannot shadow the identity; --login wins even on the tea-failure fallback arm), issue-create.sh (same), pr-merge.sh (gains --login; --dry-run reports the principal the merge WOULD act as, resolved exactly as the merge resolves it; no cross-principal fallback — an identity-bound 401 is a hard stop). Remaining wrappers are call-site conversions onto the same resolver, measured: write-path issue-assign, issue-close, issue-edit, issue-reopen, milestone-close, milestone-create, pr-close; read-path issue-list, milestone-list, pr-list, pr-view (issue-view mixed). pr-diff, pr-metadata, pr-ci-wait and ci-queue-wait already inherit identity-first resolution via get_gitea_token. Known interaction: on a host with a workstation-GLOBAL mosaic.gitIdentity, this fix activates identity mode for every seat that has not set a local one — correct behavior driven by a wrong configuration (measured: #1282-#1287, six accidental live issues, closed with provenance by fred). Set mosaic.gitIdentity per-worktree, never --global. Tests: test-gitea-principal-resolution.sh (resolver matrix — identity present/absent, --login precedence, env vs git-config, unrecognized-host containment, slot-path-by-path-never-by-value); test-pr-create-identity- first.sh (the load-bearing ordering test: identity arm REACHED on the happy path with tea never invoked, fail-loud BEFORE any write on a missing slot, --login wins, default preserved); test-pr-merge-principal- resolution.sh (dry-run truthfulness, merge credential binding, unknown --login never reaches the provider). All wired into test:framework-shell. Sabotage control: precedence inverted to tea-list-first inside the resolver -> exactly the three new suites redden with the #1280 signatures (identity resolves to the tea-list account; missing slot returns rc=0 with silent fallthrough) while all 11 pre-existing git suites stay green; restored byte-identical (sha256 verified); all 14 green again.
777 lines
35 KiB
Bash
Executable File
777 lines
35 KiB
Bash
Executable File
#!/bin/bash
|
|
# pr-review.sh - Review a pull request on GitHub or Gitea
|
|
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]
|
|
#
|
|
# Gitea reviews and comments are written through the supported REST API, not
|
|
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
|
|
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
|
|
# untrustworthy — signal it offers. approve/request-changes POST to
|
|
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
|
|
# action POSTs to /issues/{n}/comments (returns the created comment with its
|
|
# id). Each write is then verified by GETting that exact returned id, so a
|
|
# concurrent record cannot masquerade as this write and a no-op fails closed.
|
|
#
|
|
# --login override: the default login is resolved from the local tea login list
|
|
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
|
|
# override it for this invocation only. The REST write, the /user identity read,
|
|
# and every read-back are ALL performed with the token of the EFFECTIVE login,
|
|
# so the write and its verification bind to the same identity.
|
|
#
|
|
# -r/--repo override: explicit owner/repo slug, skipping git-remote slug
|
|
# inference — mirrors the -r convention of the sibling wrappers (pr-view.sh,
|
|
# pr-diff.sh, pr-ci-wait.sh; mosaicstack/stack #867) for reviewer worktrees
|
|
# whose origin is nonstandard or missing. -H/--host makes the target Gitea
|
|
# instance explicit too (skips remote-host inference), so ambient CWD/remote
|
|
# state can no longer cross-wire the review to the wrong instance. With -r, the
|
|
# resolved repo is preflighted (GET .../repos/<slug>) BEFORE any write so a
|
|
# wrong-host cross-wire surfaces as a clear preflight error instead of an opaque
|
|
# write-404. Every Gitea curl (write, read-back, preflight) carries a
|
|
# `User-Agent: mosaic-pr-review` header, since some Cloudflare-fronted Gitea
|
|
# hosts intermittently reject curl's default User-Agent.
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
# Parse arguments
|
|
PR_NUMBER=""
|
|
ACTION=""
|
|
COMMENT=""
|
|
LOGIN_OVERRIDE=""
|
|
REPO_OVERRIDE=""
|
|
HOST_OVERRIDE=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-n|--number)
|
|
PR_NUMBER="$2"
|
|
shift 2
|
|
;;
|
|
-a|--action)
|
|
ACTION="$2"
|
|
shift 2
|
|
;;
|
|
-c|--comment)
|
|
COMMENT="$2"
|
|
shift 2
|
|
;;
|
|
-l|--login)
|
|
LOGIN_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-r|--repo)
|
|
REPO_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-H|--host)
|
|
HOST_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -n, --number PR number (required)"
|
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
|
echo " -c, --comment Review comment (required for request-changes)"
|
|
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
|
|
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
|
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
|
echo " -h, --help Show this help"
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1"
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$PR_NUMBER" ]]; then
|
|
echo "Error: PR number is required (-n)"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$ACTION" ]]; then
|
|
echo "Error: Action is required (-a): approve, request-changes, comment"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
|
# An explicit --repo is the whole point of a reviewer worktree whose origin
|
|
# is nonstandard or missing (#867 convention, mirrored from pr-view.sh /
|
|
# pr-diff.sh): do not hard-fail platform detection on a missing/foreign
|
|
# origin — assume gitea, the only platform --repo/--host target.
|
|
detect_platform >/dev/null 2>&1 || PLATFORM="gitea"
|
|
else
|
|
detect_platform >/dev/null
|
|
fi
|
|
|
|
# Render the provider's own explanation for a failed request, for appending to
|
|
# an error message (#1004). Every HTTP arm in this file already has the response
|
|
# body on disk; without this it was discarded unread at exactly the moment the
|
|
# caller needed it, which pushes an operator toward re-issuing the request by
|
|
# hand to find out what the server said. Gitea returns {"message": "..."} on a
|
|
# refusal; anything unparseable falls back to a truncated raw first line so a
|
|
# proxy's HTML error page still says something. Prints "" when there is nothing
|
|
# to add, so callers can interpolate unconditionally.
|
|
#
|
|
# Args: $1 = path to the response body file.
|
|
gitea_error_detail() {
|
|
local body_file="$1"
|
|
[[ -s "$body_file" ]] || return 0
|
|
python3 - "$body_file" <<'PY' 2>/dev/null || true
|
|
import json
|
|
import sys
|
|
|
|
LIMIT = 300
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
|
|
raw = response.read().strip()
|
|
except OSError:
|
|
raise SystemExit(0)
|
|
if not raw:
|
|
raise SystemExit(0)
|
|
detail = ""
|
|
try:
|
|
parsed = json.loads(raw)
|
|
if isinstance(parsed, dict):
|
|
for key in ("message", "error", "errors"):
|
|
value = parsed.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
detail = value.strip()
|
|
break
|
|
if isinstance(value, list) and value:
|
|
detail = "; ".join(str(item) for item in value).strip()
|
|
break
|
|
except ValueError:
|
|
pass
|
|
if not detail:
|
|
detail = raw.splitlines()[0].strip()
|
|
if not detail:
|
|
raise SystemExit(0)
|
|
if len(detail) > LIMIT:
|
|
detail = detail[:LIMIT] + "..."
|
|
print(f" — provider said: {detail}")
|
|
PY
|
|
}
|
|
|
|
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
|
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
|
# write is a direct POST that returns the created comment object, so we learn
|
|
# the exact id of THIS write; we GET that exact id and require id == created id
|
|
# AND author == acting identity AND exact body AND that it belongs to this PR.
|
|
# Keying to the returned id means no concurrent comment (even same identity /
|
|
# body) can masquerade as this write, and a no-op create yields no id and fails
|
|
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
|
|
# gitea_resolve_api_for_login). Prints the created comment id on success.
|
|
#
|
|
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
|
|
gitea_create_comment_verified() {
|
|
local pr_number="$1" comment_body="$2" acting_login="$3"
|
|
local payload write_file readback_file auth_config write_status readback_status created_id
|
|
|
|
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
|
import json
|
|
import os
|
|
|
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|
')
|
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$write_file" "$readback_file"
|
|
echo "Error: could not stage Gitea credential for comment write" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
|
|
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
|
-X POST \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$payload" \
|
|
"$GITEA_API_BASE/issues/$pr_number/comments"); then
|
|
echo "Error: Gitea comment write transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$write_status" != "201" ]]; then
|
|
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
|
return 1
|
|
fi
|
|
|
|
created_id=$(python3 - "$write_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
comment = json.load(response)
|
|
created_id = comment.get("id") if isinstance(comment, dict) else None
|
|
if not isinstance(created_id, int) or created_id <= 0:
|
|
raise ValueError("create response carried no positive comment id")
|
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(created_id)
|
|
PY
|
|
) || return 1
|
|
|
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
|
echo "Error: Gitea comment read-back transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$readback_status" != "200" ]]; then
|
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
|
return 1
|
|
fi
|
|
|
|
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
|
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
|
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
|
python3 - "$readback_file" <<'PY' || return 1
|
|
import json
|
|
import os
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def _origin_and_path(url):
|
|
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
|
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
|
# port and its explicit default form compare equal.
|
|
parsed = urlparse(url or "")
|
|
scheme = (parsed.scheme or "").lower()
|
|
host = (parsed.hostname or "").lower()
|
|
default_port = 80 if scheme == "http" else 443
|
|
port = parsed.port if parsed.port is not None else default_port
|
|
return (scheme, host, port), parsed.path.rstrip("/")
|
|
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
comment = json.load(response)
|
|
if not isinstance(comment, dict):
|
|
raise ValueError("response is not a comment object")
|
|
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
|
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
|
acting_login = os.environ["ACTING_LOGIN"]
|
|
slug = os.environ["EXPECTED_REPO_SLUG"]
|
|
number = os.environ["EXPECTED_NUMBER"]
|
|
web_base = os.environ["EXPECTED_WEB_BASE"]
|
|
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
|
|
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
|
|
# issue_url empty), while a plain issue comment carries
|
|
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
|
|
# This is the pr-review `comment` action, so the comment MUST land on a pull
|
|
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
|
|
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
|
|
# comment, and accepting that issue_url would let the wrapper falsely report a
|
|
# verified PR comment (issue-comment.sh legitimately keeps the broader
|
|
# issue-or-PR acceptance; a PR review does not).
|
|
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
|
# provider + repo + kind + number — an endswith/suffix test would accept a
|
|
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
|
|
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
|
|
base_origin, base_path = _origin_and_path(web_base)
|
|
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
|
|
|
def _belongs(url, expected_path):
|
|
if not url:
|
|
return False
|
|
origin, path = _origin_and_path(url)
|
|
# Repo owner/repo slugs are case-insensitive (Gitea canonicalizes the
|
|
# pull_request_url slug to lowercase on return), while EXPECTED_REPO_SLUG
|
|
# is taken verbatim from GITEA_API_BASE and may be mixed-case. The
|
|
# remainder of the path (".../pulls/<number>") is numeric, so lowercasing
|
|
# the whole path for this comparison only relaxes case, not identity: the
|
|
# origin tuple (scheme+host+port) above still pins the provider host, and
|
|
# the path is still compared in FULL (no endswith/suffix match), so the
|
|
# look-alike-host and same-host decoy-prefix protections are unchanged.
|
|
return origin == base_origin and path.lower() == expected_path.lower()
|
|
|
|
if comment.get("id") != expected_id:
|
|
raise ValueError("read-back id does not match the created id")
|
|
if (comment.get("user") or {}).get("login") != acting_login:
|
|
raise ValueError("created comment is not authored by the acting identity")
|
|
if comment.get("body") != expected_body:
|
|
raise ValueError("created comment body does not match")
|
|
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
|
|
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
|
|
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
echo "$created_id"
|
|
return 0
|
|
}
|
|
|
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
|
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
|
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
|
#
|
|
# The token is resolved for the EFFECTIVE login (the --login override when
|
|
# given, otherwise the detected default), so the one credential used to submit
|
|
# the review/comment ALSO drives the /user identity read and every read-back —
|
|
# write token and read-back token are the same identity by construction. This
|
|
# is the credential-ordering fix: a --login override is no longer submitted
|
|
# under one credential and verified under a different default one. Falls back to
|
|
# the host-scoped credential ONLY when NO --login override was supplied (the
|
|
# best-effort default path). When $2 is "explicit" the login came from a
|
|
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
|
# CLOSED rather than silently downgrading the review/comment to the host default
|
|
# identity. Returns non-zero (clear stderr) on any resolution failure.
|
|
#
|
|
# Honors the module-level REPO_OVERRIDE / HOST_OVERRIDE (-r/--repo, -H/--host):
|
|
# when set, they skip git-remote slug/host inference entirely — for reviewer
|
|
# worktrees whose origin is nonstandard or missing, and to make the target
|
|
# instance fully deterministic (an ambient CWD/remote can otherwise cross-wire
|
|
# a review to the wrong Gitea host). When -r/--repo is used, the resolved repo
|
|
# is preflighted (GET .../repos/<slug>) BEFORE any write: a wrong-host
|
|
# cross-wire would otherwise surface only as an opaque write-404 with zero
|
|
# residue.
|
|
gitea_resolve_api_for_login() {
|
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
|
local preflight_auth_config preflight_status
|
|
|
|
if [[ -n "$HOST_OVERRIDE" ]]; then
|
|
host="$HOST_OVERRIDE"
|
|
else
|
|
host=$(get_remote_host)
|
|
fi
|
|
if [[ "$override_explicit" == "identity" ]]; then
|
|
# Requested git identity (#1280): the per-slot token MUST resolve via
|
|
# get_gitea_token's identity arm; never borrow the tea default login.
|
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2
|
|
return 1
|
|
}
|
|
elif [[ -n "$override_explicit" ]]; then
|
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
|
return 1
|
|
}
|
|
else
|
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
|
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
|
return 1
|
|
}
|
|
fi
|
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
|
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
|
|
return 1
|
|
}
|
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
|
repo="$REPO_OVERRIDE"
|
|
else
|
|
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
|
return 1
|
|
}
|
|
fi
|
|
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
|
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
|
# The provider WEB base (scheme + host + effective port + any deployment path
|
|
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
|
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
|
# not just a repo/PR suffix.
|
|
GITEA_WEB_BASE="${configured_url%/}"
|
|
|
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
|
preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
echo "Error: could not stage Gitea credential for --repo preflight" >&2
|
|
return 1
|
|
}
|
|
preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
|
--config "$preflight_auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
"$GITEA_API_BASE") || preflight_status="000"
|
|
rm -f "$preflight_auth_config"
|
|
if [[ "$preflight_status" != "200" ]]; then
|
|
echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host <gitea-host> or cd into the target checkout" >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Resolve the login of the identity the API token authenticates as (GET
|
|
# /user). Used to attribute a read-back review to THIS action's reviewer so a
|
|
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
|
# Prints the login on success.
|
|
gitea_authenticated_login() {
|
|
local response_file auth_config status
|
|
|
|
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$response_file"
|
|
echo "Error: could not stage Gitea credential for identity read" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
|
|
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
"$GITEA_API_ROOT/user"); then
|
|
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$status" != "200" ]]; then
|
|
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
|
|
return 1
|
|
fi
|
|
|
|
python3 - "$response_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
user = json.load(response)
|
|
login = user.get("login") if isinstance(user, dict) else None
|
|
if not isinstance(login, str) or not login:
|
|
raise ValueError("missing authenticated login")
|
|
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
|
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(login)
|
|
PY
|
|
}
|
|
|
|
# GET /pulls/{n} into a caller-owned response file and print its head commit
|
|
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
|
|
# response file, so it is safe to call from INSIDE another trapped function
|
|
# (the post-verify re-read below) without clobbering that function's cleanup
|
|
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
|
|
gitea_read_pr_head_into() {
|
|
local pr_number="$1" pr_file="$2" auth_config="$3" status
|
|
|
|
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
"$GITEA_API_BASE/pulls/$pr_number"); then
|
|
echo "Error: Gitea PR head read transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$status" != "200" ]]; then
|
|
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
|
|
return 1
|
|
fi
|
|
python3 - "$pr_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
pr = json.load(response)
|
|
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
|
|
if not isinstance(head_sha, str) or not head_sha:
|
|
raise ValueError("missing PR head sha")
|
|
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
|
|
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(head_sha)
|
|
PY
|
|
}
|
|
|
|
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
|
|
# submitted against — and later verified as pinned to — this exact commit, so a
|
|
# stale review left over from an earlier push cannot be mistaken for this one.
|
|
# Prints the head SHA on success.
|
|
gitea_pr_head_sha() {
|
|
local pr_number="$1" pr_file auth_config
|
|
|
|
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$pr_file"
|
|
echo "Error: could not stage Gitea credential for PR head read" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$pr_file" "$auth_config"' RETURN
|
|
|
|
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
|
|
}
|
|
|
|
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
|
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
|
|
# the id of the review it created and can silently no-op while exiting 0 (#865
|
|
# defect class), so this does NOT shell out to tea: it POSTs to
|
|
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
|
|
# commit_id, and the review body, which returns the created review object
|
|
# including its id. It then GETs that exact review id and requires
|
|
# id == created id AND author == acting identity AND state == expected AND
|
|
# commit_id == PR head. Keying to the returned id means no concurrent review
|
|
# (even same identity/state/head) can masquerade as this one, and a no-op
|
|
# submit yields no id and fails closed. Prints the created review id on success.
|
|
#
|
|
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
|
|
# $3 = review body (may be empty for APPROVED), $4 = acting login,
|
|
# $5 = PR head sha.
|
|
gitea_submit_review_verified() {
|
|
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
|
|
local payload write_file readback_file recheck_file auth_config
|
|
local write_status readback_status created_id live_head
|
|
|
|
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
|
|
import json
|
|
import os
|
|
|
|
print(json.dumps({
|
|
"event": os.environ["REVIEW_EVENT"],
|
|
"body": os.environ["REVIEW_BODY"],
|
|
"commit_id": os.environ["REVIEW_COMMIT"],
|
|
}))
|
|
')
|
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
|
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
|
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$write_file" "$readback_file" "$recheck_file"
|
|
echo "Error: could not stage Gitea credential for review submit" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
|
|
|
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
|
-X POST \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$payload" \
|
|
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
|
|
echo "Error: Gitea review submit transport failed" >&2
|
|
return 1
|
|
fi
|
|
# Gitea returns 200 (occasionally 201) with the created review object.
|
|
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
|
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
|
return 1
|
|
fi
|
|
|
|
created_id=$(python3 - "$write_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
review = json.load(response)
|
|
created_id = review.get("id") if isinstance(review, dict) else None
|
|
if not isinstance(created_id, int) or created_id <= 0:
|
|
raise ValueError("submit response carried no positive review id")
|
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(created_id)
|
|
PY
|
|
) || return 1
|
|
|
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
-H 'User-Agent: mosaic-pr-review' \
|
|
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
|
|
echo "Error: Gitea review read-back transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$readback_status" != "200" ]]; then
|
|
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
|
return 1
|
|
fi
|
|
|
|
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
|
|
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
|
|
python3 - "$readback_file" <<'PY' || return 1
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
review = json.load(response)
|
|
if not isinstance(review, dict):
|
|
raise ValueError("response is not a review object")
|
|
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
|
|
expected_state = os.environ["EXPECTED_STATE"]
|
|
acting_login = os.environ["ACTING_LOGIN"]
|
|
expected_head = os.environ["EXPECTED_HEAD_SHA"]
|
|
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
|
|
if review.get("id") != expected_id:
|
|
raise ValueError("read-back id does not match the created id")
|
|
if (review.get("user") or {}).get("login") != acting_login:
|
|
raise ValueError("created review is not authored by the acting identity")
|
|
if review.get("state") != expected_state:
|
|
raise ValueError("created review is not in the expected state")
|
|
if review.get("commit_id") != expected_head:
|
|
raise ValueError("created review is not pinned to the PR head commit")
|
|
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
|
|
# finalize/reuse a pending review id whose Content was authored elsewhere;
|
|
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
|
|
# review carrying different Content) fails closed even when id/author/state/
|
|
# head all line up. Require presence + string TYPE + exact equality rather
|
|
# than `(body or "")`: the old coalesce treated a missing/null persisted body
|
|
# as equal to an empty submitted one, so a non-empty submitted body that
|
|
# persisted as null (a suppressed/lost body) would have passed. When a
|
|
# non-empty body was submitted the persisted value MUST be that exact string;
|
|
# when an empty body was submitted the persisted value must be empty or
|
|
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
|
|
persisted_body = review.get("body")
|
|
if expected_body == "":
|
|
if persisted_body not in (None, ""):
|
|
raise ValueError("created review carries a body but none was submitted")
|
|
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
|
|
raise ValueError("created review body does not match the submitted body")
|
|
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
# Current-head TOCTOU close-out: the review verified above is pinned to
|
|
# head_sha, but that head was read BEFORE the submit. Between then and now
|
|
# the PR branch may have advanced (a force-push or a new commit), which would
|
|
# leave this verified review attached to a now-superseded commit while the
|
|
# live tip carries unreviewed code — yet the wrapper would still report
|
|
# success. Re-read the LIVE PR head and require it STILL equals the submitted
|
|
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
|
|
# success line). This reuses the submit-scoped auth config + recheck file so
|
|
# it neither leaks the token to argv nor clobbers this function's cleanup.
|
|
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
|
|
echo "Error: could not re-read Gitea PR head after review verification" >&2
|
|
return 1
|
|
}
|
|
if [[ "$live_head" != "$head_sha" ]]; then
|
|
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "$created_id"
|
|
return 0
|
|
}
|
|
|
|
if [[ "$PLATFORM" == "github" ]]; then
|
|
case $ACTION in
|
|
approve)
|
|
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
|
|
echo "Approved GitHub PR #$PR_NUMBER"
|
|
;;
|
|
request-changes)
|
|
if [[ -z "$COMMENT" ]]; then
|
|
echo "Error: Comment required for request-changes"
|
|
exit 1
|
|
fi
|
|
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
|
|
echo "Requested changes on GitHub PR #$PR_NUMBER"
|
|
;;
|
|
comment)
|
|
if [[ -z "$COMMENT" ]]; then
|
|
echo "Error: Comment required"
|
|
exit 1
|
|
fi
|
|
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
|
|
echo "Added review comment to GitHub PR #$PR_NUMBER"
|
|
;;
|
|
*)
|
|
echo "Error: Unknown action: $ACTION"
|
|
exit 1
|
|
;;
|
|
esac
|
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
|
# Resolve the acting principal ONCE for every action, identity-first
|
|
# (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY /
|
|
# per-worktree git config mosaic.gitIdentity selects the principal when a
|
|
# per-slot token exists (fail-loud when it does not); the tea login list is
|
|
# the LAST resort — it enumerates whatever logins this host happens to hold
|
|
# and knows nothing about which seat is calling, so resolving from it first
|
|
# wrote under whichever account tea had configured (the #1280 family).
|
|
principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
|
exit 1
|
|
fi
|
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
|
case $ACTION in
|
|
approve)
|
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
|
# PRINCIPAL_NAME were resolved once above from --login >
|
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
|
else
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
|
fi
|
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
|
# The review body (if any) travels with the review itself in the REST
|
|
# submit — the created review record carries it — so there is no
|
|
# separate detached comment to reconcile.
|
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
|
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
exit 1
|
|
}
|
|
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
|
|
;;
|
|
request-changes)
|
|
if [[ -z "$COMMENT" ]]; then
|
|
echo "Error: Comment required for request-changes"
|
|
exit 1
|
|
fi
|
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
|
# PRINCIPAL_NAME were resolved once above from --login >
|
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
|
else
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
|
fi
|
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
|
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
exit 1
|
|
}
|
|
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
|
|
;;
|
|
comment)
|
|
if [[ -z "$COMMENT" ]]; then
|
|
echo "Error: Comment required"
|
|
exit 1
|
|
fi
|
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
|
# PRINCIPAL_NAME were resolved once above from --login >
|
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
|
else
|
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
|
fi
|
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
|
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
|
exit 1
|
|
}
|
|
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
|
;;
|
|
*)
|
|
echo "Error: Unknown action: $ACTION"
|
|
exit 1
|
|
;;
|
|
esac
|
|
else
|
|
echo "Error: Unknown platform"
|
|
exit 1
|
|
fi
|