diff --git a/packages/mosaic/framework/tools/git/pr-review.sh b/packages/mosaic/framework/tools/git/pr-review.sh index ba296d2e..ce10fe98 100755 --- a/packages/mosaic/framework/tools/git/pr-review.sh +++ b/packages/mosaic/framework/tools/git/pr-review.sh @@ -109,6 +109,55 @@ 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 @@ -150,7 +199,7 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]})) return 1 fi if [[ "$write_status" != "201" ]]; then - echo "Error: Gitea comment write failed with HTTP $write_status" >&2 + echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2 return 1 fi @@ -179,7 +228,7 @@ PY return 1 fi if [[ "$readback_status" != "200" ]]; then - echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2 + echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2 return 1 fi @@ -370,7 +419,7 @@ gitea_authenticated_login() { return 1 fi if [[ "$status" != "200" ]]; then - echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2 + echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2 return 1 fi @@ -407,7 +456,7 @@ gitea_read_pr_head_into() { return 1 fi if [[ "$status" != "200" ]]; then - echo "Error: Gitea PR head read failed with HTTP $status" >&2 + echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2 return 1 fi python3 - "$pr_file" <<'PY' @@ -497,7 +546,7 @@ print(json.dumps({ 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 (#865: no durable review created)" >&2 + echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2 return 1 fi @@ -526,7 +575,7 @@ PY return 1 fi if [[ "$readback_status" != "200" ]]; then - echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2 + echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2 return 1 fi diff --git a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh index cf1de2fb..588ca0bc 100644 --- a/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh +++ b/packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh @@ -58,6 +58,9 @@ CREDENTIALS_FILE="$WORK_DIR/credentials.json" # A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak # check can assert every POST/GET body + metadata temp file is cleaned up. TMP_SCRATCH="$WORK_DIR/scratch" +# Sandboxed HOME so nothing under the real $HOME (notably the per-slot Gitea token +# store at ~/.config/mosaic/secrets/gitea-tokens/) is reachable from the wrapper. +HOME_DIR="$WORK_DIR/home" cleanup() { rm -rf "$WORK_DIR" @@ -78,9 +81,18 @@ OVERRIDE_TOKEN="override-token-placeholder" CROSS_HOST_LOGIN="foreign-host-reviewer" CROSS_HOST_TOKEN="cross-host-token-placeholder" -mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH" +mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git +# HERMETICITY: get_gitea_token() step 0 resolves a per-agent identity from +# `git config --get mosaic.gitIdentity`, which on a provisioned agent seat is set +# GLOBALLY and therefore leaks into this fresh repo. It then reads a REAL per-slot +# token from $HOME and returns it WITHOUT ever consulting MOSAIC_CREDENTIALS_FILE, +# so the fixture credentials below are silently ignored and the suite runs against +# production credentials. An empty repo-local value shadows the global one and reads +# back as empty at rc=0, restoring the shared-credential path this suite intends to +# exercise. Paired with the sandboxed HOME in run_review(). +git -C "$REPO_DIR" config mosaic.gitIdentity "" # tea config: the override login carries its own token here. The default login # name ("mosaicstack") is deliberately absent, so the no-override default path @@ -266,6 +278,24 @@ submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"]) with open(state_path, encoding="utf-8") as handle: reviews = json.load(handle) +# review-refused-422 (#1004): the server REFUSES the submit outright with a +# definite, correct, machine-readable reason in the body — the shape Gitea +# returns when the acting credential authored the PR. Nothing is created. The +# wrapper must surface what the server said and must NOT relabel this as the +# #865 silent-no-op defect class, which is precisely what it is not. +if mode == "review-refused-422": + print("422") + print(json.dumps({"message": "Cannot approve your own pull request"})) + raise SystemExit(0) + +# review-refused-html (#1004): a non-JSON error body, as a fronting proxy or +# gateway emits. The detail extraction must degrade to the first raw line rather +# than silently dropping the only explanation available. +if mode == "review-refused-html": + print("502") + print("