#!/bin/bash # pr-review.sh - Review a pull request on GitHub or Gitea # Usage: pr-review.sh -n -a [-c ] [--login ] # # --login override: approve/request-changes on Gitea invoke `tea pr # approve`/`tea pr reject` with a `--login` resolved from the local tea # login list for this repo's host (get_gitea_login_for_host). Pass # --login to override that default for this invocation only. The # override is appended to the tea command line AFTER the detected default # (get_gitea_repo_args()-equivalent resolution happens first), because tea # honors only the LAST `--login` flag on its command line — a flag placed # before the default would be silently clobbered by it. The `comment` # action does not shell out to `tea` at all (see gitea_post_verified_comment # below), so --login has no effect on it. 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="" 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 ;; -h|--help) echo "Usage: pr-review.sh -n -a [-c ] [--login ]" 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 (approve/request-changes only)" 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 detect_platform >/dev/null # Post a review comment body to a Gitea PR via the supported comments REST API # and verify it durably via provider read-back (see docs on durable review # provenance in README.md). Used by the `comment` action and, since `tea` # v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`, # also by the `approve` and `request-changes` actions to carry an optional # review body that `tea` itself cannot attach. # # Args: $1 = PR number, $2 = comment body # On success: prints only the created comment ID to stdout, returns 0. # On failure: prints an error to stderr, returns 1. gitea_post_verified_comment() { local pr_number="$1" comment_body="$2" local host token configured_url repo api_base payload local write_response_file readback_response_file comment_id host=$(get_remote_host) token=$(get_gitea_token "$host") || { echo "Error: Gitea token not found for comment persistence" >&2 return 1 } configured_url=$(get_gitea_url_for_host "$host") || { echo "Error: Configured Gitea URL not found for comment persistence" >&2 return 1 } repo=$(get_gitea_repo_slug_for_url "$configured_url") || { echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2 return 1 } api_base="${configured_url%/}/api/v1/repos/$repo" payload=$(COMMENT_BODY="$comment_body" python3 -c ' import json import os print(json.dumps({"body": os.environ["COMMENT_BODY"]})) ') write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX") readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX") trap 'rm -f "$write_response_file" "$readback_response_file"' RETURN if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \ -X POST \ -H "Authorization: token $token" \ -H 'Content-Type: application/json' \ -d "$payload" \ "$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" >&2 return 1 fi comment_id=$(python3 - "$write_response_file" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as response: comment = json.load(response) comment_id = comment.get("id") if isinstance(comment, dict) else None if not isinstance(comment_id, int) or comment_id <= 0: raise ValueError("missing 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(comment_id) PY ) || return 1 if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \ -H "Authorization: token $token" \ "$api_base/issues/comments/$comment_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" >&2 return 1 fi if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$pr_number" \ python3 - "$readback_response_file" <<'PY' import json import os import sys from urllib.parse import urlparse 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"] expected_repo = os.environ["EXPECTED_REPO"] expected_pr = os.environ["EXPECTED_PR_NUMBER"] issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/") expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}" if comment.get("id") != expected_id: raise ValueError("comment id mismatch") if comment.get("body") != expected_body: raise ValueError("comment body mismatch") if not issue_path.endswith(expected_suffix): raise ValueError("repository or PR mismatch") 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 then true else return 1 fi echo "$comment_id" return 0 } # Resolve and cache the Gitea REST endpoint + token for the current remote. # Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE (…/api/v1/repos/), # and GITEA_API_TOKEN. Returns non-zero (with a clear stderr message) on any # resolution failure. gitea_resolve_api() { local host configured_url repo host=$(get_remote_host) GITEA_API_TOKEN=$(get_gitea_token "$host") || { echo "Error: Gitea token not found for review read-back verification" >&2 return 1 } configured_url=$(get_gitea_url_for_host "$host") || { echo "Error: Configured Gitea URL not found for review read-back verification" >&2 return 1 } repo=$(get_gitea_repo_slug_for_url "$configured_url") || { echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2 return 1 } GITEA_API_ROOT="${configured_url%/}/api/v1" GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo" return 0 } # Fetch every page of a Gitea list endpoint into $2 (merged into one JSON # array). Gitea paginates list responses, so a single-page read would # false-negative once a newly created review/comment lands beyond page 1. # Walks page=1,2,… until a short or empty page is returned so the merged array # is exhaustive. $1 is the endpoint URL with NO query string. Returns non-zero # (clear stderr) on any transport / HTTP / parse failure. gitea_fetch_all() { local base_url="$1" dest="$2" page=1 limit=50 status page_file count printf '[]' > "$dest" while :; do page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-page.XXXXXX") if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \ -H "Authorization: token $GITEA_API_TOKEN" \ "${base_url}?limit=${limit}&page=${page}"); then rm -f "$page_file" echo "Error: Gitea list read transport failed" >&2 return 1 fi if [[ "$status" != "200" ]]; then rm -f "$page_file" echo "Error: Gitea list read failed with HTTP $status" >&2 return 1 fi count=$(DEST="$dest" python3 - "$page_file" <<'PY' import json import os import sys try: with open(os.environ["DEST"], encoding="utf-8") as merged_file: merged = json.load(merged_file) with open(sys.argv[1], encoding="utf-8") as page_file: page = json.load(page_file) if not isinstance(page, list): raise ValueError("page response is not a list") merged.extend(item for item in page if isinstance(item, dict)) with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file: json.dump(merged, merged_file) except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr) raise SystemExit(1) print(len(page)) PY ) || { rm -f "$page_file"; return 1; } rm -f "$page_file" [[ "$count" -lt "$limit" ]] && break page=$((page + 1)) if [[ "$page" -gt 1000 ]]; then echo "Error: Gitea list pagination exceeded 1000 pages" >&2 return 1 fi done 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 status response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX") trap 'rm -f "$response_file"' RETURN if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \ -H "Authorization: token $GITEA_API_TOKEN" \ "$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" >&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 } # Print the maximum existing review id on a PR (0 if none). This is the # pre-write BOUNDARY: Gitea pull-review ids are monotonic, so any review # submitted by a subsequent `tea pr approve`/`reject` has an id strictly # greater than this value. Paginates fully so a boundary review beyond page 1 # is still counted. gitea_max_review_id() { local pr_number="$1" merged_file merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-boundary.XXXXXX") trap 'rm -f "$merged_file"' RETURN gitea_fetch_all "$GITEA_API_BASE/pulls/$pr_number/reviews" "$merged_file" || return 1 python3 - "$merged_file" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as response: reviews = json.load(response) ids = [r.get("id") for r in reviews if isinstance(r, dict) and isinstance(r.get("id"), int)] print(max(ids) if ids else 0) except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: print(f"Error: could not compute Gitea review boundary: {error}", file=sys.stderr) raise SystemExit(1) PY } # Independently verify that `tea pr approve`/`reject` produced a durable review # record — never trust tea's exit code alone (#865, same defect class). Require # a review attributable to THIS action: its id must be strictly greater than # the pre-write boundary, its author login must equal the acting identity, its # state must equal the expected state (APPROVED / REQUEST_CHANGES), and it must # have been submitted against the PR's current head commit. The reviews list is # paginated fully so a matching review beyond page 1 is still found. Prints the # matched review id on success; fails closed (non-zero, clear stderr) if no # such review is found. # # id-above-boundary alone is only temporal ordering; the author-login check is # what excludes a concurrent review submitted by a DIFFERENT identity. # # Residual (documented, not eliminable without a tea-emitted created-record id, # which tea 0.11.1 does not reliably provide for approve/reject): a concurrent # review by the SAME identity with the same state against the same head inside # the boundary window could still be accepted. That is strictly narrower than # temporal-only matching. # # Args: $1 = PR number, $2 = expected state (APPROVED|REQUEST_CHANGES), # $3 = pre-write boundary review id, $4 = acting reviewer login. gitea_verify_review_submitted() { local pr_number="$1" expected_state="$2" boundary="$3" acting_login="$4" local pr_response_file reviews_merged_file status head_sha review_id pr_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX") reviews_merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-state.XXXXXX") trap 'rm -f "$pr_response_file" "$reviews_merged_file"' RETURN # Resolve the PR's current head commit so the review can be pinned to it. if ! status=$(curl -sS -o "$pr_response_file" -w '%{http_code}' \ -H "Authorization: token $GITEA_API_TOKEN" \ "$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" >&2 return 1 fi head_sha=$(python3 - "$pr_response_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 ) || return 1 gitea_fetch_all "$GITEA_API_BASE/pulls/$pr_number/reviews" "$reviews_merged_file" || return 1 review_id=$(EXPECTED_STATE="$expected_state" BOUNDARY_REVIEW_ID="$boundary" EXPECTED_HEAD_SHA="$head_sha" ACTING_LOGIN="$acting_login" \ python3 - "$reviews_merged_file" <<'PY' import json import os import sys try: with open(sys.argv[1], encoding="utf-8") as response: reviews = json.load(response) if not isinstance(reviews, list): raise ValueError("response is not a review list") expected_state = os.environ["EXPECTED_STATE"] boundary = int(os.environ["BOUNDARY_REVIEW_ID"]) expected_head = os.environ["EXPECTED_HEAD_SHA"] acting_login = os.environ["ACTING_LOGIN"] # Attribution to THIS action: created-after-boundary AND submitted by the # acting reviewer identity AND expected state AND pinned to the PR's # current head commit. The author check excludes a concurrent # DIFFERENT-identity review that id+state+head alone would admit. matches = [ r for r in reviews if isinstance(r, dict) and isinstance(r.get("id"), int) and r.get("id") > boundary and (r.get("user") or {}).get("login") == acting_login and r.get("state") == expected_state and r.get("commit_id") == expected_head ] if not matches: raise ValueError( f"no {expected_state} review attributable to this action found " "(id > boundary, acting identity, expected state, current head); " "tea may have silently failed (#865 defect class)" ) review_id = max(r["id"] for r in matches) except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error: print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr) raise SystemExit(1) print(review_id) PY ) || return 1 echo "$review_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 case $ACTION in approve) repo=$(get_repo_slug) host=$(get_remote_host) login=$(get_gitea_login_for_host "$host") # Resolve the REST endpoint and record the pre-write review-id # boundary BEFORE the write, so the read-back can require a # strictly-newer review created by THIS action (never trust tea's # exit code alone — #865 defect class applies to the review state). gitea_resolve_api || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1 # tea v0.11.1 defines no --comment/-comment flag on `pr approve`; # route any review body via the durable comment API instead (#835). TEA_ARGS=(pr approve "$PR_NUMBER" --repo "$repo" --login "$login") # --login override goes LAST: tea honors only the final --login on # its command line, so an override placed before the detected # default above would be silently clobbered by it. if [[ -n "$LOGIN_OVERRIDE" ]]; then TEA_ARGS+=(--login "$LOGIN_OVERRIDE") fi tea "${TEA_ARGS[@]}" review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "APPROVED" "$review_boundary" "$ACTING_LOGIN") || { echo "Error: could not verify an APPROVED review landed on Gitea PR #$PR_NUMBER via bounded read-back; treating tea's exit code as untrustworthy (#865)." >&2 exit 1 } echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)" if [[ -n "$COMMENT" ]]; then comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1 echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)" fi ;; request-changes) if [[ -z "$COMMENT" ]]; then echo "Error: Comment required for request-changes" exit 1 fi repo=$(get_repo_slug) host=$(get_remote_host) login=$(get_gitea_login_for_host "$host") # Record the pre-write review-id boundary BEFORE the write (see the # approve path above for the rationale). gitea_resolve_api || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1 # tea v0.11.1 defines no --comment/-comment flag on `pr reject`; # route the review body via the durable comment API instead (#835). TEA_ARGS=(pr reject "$PR_NUMBER" --repo "$repo" --login "$login") # --login override goes LAST: tea honors only the final --login on # its command line, so an override placed before the detected # default above would be silently clobbered by it. if [[ -n "$LOGIN_OVERRIDE" ]]; then TEA_ARGS+=(--login "$LOGIN_OVERRIDE") fi tea "${TEA_ARGS[@]}" review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "REQUEST_CHANGES" "$review_boundary" "$ACTING_LOGIN") || { echo "Error: could not verify a REQUEST_CHANGES review landed on Gitea PR #$PR_NUMBER via bounded read-back; treating tea's exit code as untrustworthy (#865)." >&2 exit 1 } echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)" comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1 echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)" ;; comment) if [[ -z "$COMMENT" ]]; then echo "Error: Comment required" exit 1 fi comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || 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