From 16481ece3df8d6839d260d960fbc36c3731e41b6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 21 Jul 2026 19:02:25 -0500 Subject: [PATCH] fix(tools): attribute read-back to acting identity and paginate fully (#865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 remediation for the read-back verification in issue-comment.sh and pr-review.sh. BLOCKER A (invocation attribution): id-above-boundary + content/state match only proves temporal ordering — a concurrent write from a different identity could satisfy it while this tea invocation created nothing. Both wrappers now resolve the acting identity once via curl GET /api/v1/user and additionally require the accepted record's author login to equal that identity. Residual same-identity same-body/state concurrency is documented in-code (tea 0.11.1 emits no reliable created-record id to close it further). BLOCKER B (pagination): the comments and reviews list reads now walk every page (?limit=&page=1,2,… until a short/empty page) for both the pre-write boundary and the post-write read-back, so a record beyond page 1 is still found. Adds regressions: concurrent different-identity write fails closed (comments and reviews); a matching review beyond page 1 is still found. README updated. Co-Authored-By: Claude Opus 4.8 --- packages/mosaic/framework/tools/git/README.md | 8 +- .../framework/tools/git/issue-comment.sh | 175 ++++++++++++----- .../mosaic/framework/tools/git/pr-review.sh | 185 +++++++++++++----- .../tools/git/test-issue-comment-readback.sh | 109 ++++++++--- .../tools/git/test-pr-review-gitea-comment.sh | 130 +++++++++--- 5 files changed, 461 insertions(+), 146 deletions(-) diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 4db7da6a..912de1bb 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -6,9 +6,13 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write. -`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes. The `approve` and `request-changes` actions apply the same discipline to the review **state** itself: they record the maximum existing review id _before_ invoking `tea pr approve`/`reject`, then require a review whose id is strictly greater than that boundary, whose state matches the requested action (`APPROVED` / `REQUEST_CHANGES`), and whose reviewed commit equals the PR's current head. tea's exit code alone is never treated as evidence the review landed. +`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes. The `approve` and `request-changes` actions apply the same discipline to the review **state** itself: they record the maximum existing review id _before_ invoking `tea pr approve`/`reject`, then require a review whose id is strictly greater than that boundary, whose **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), whose state matches the requested action (`APPROVED` / `REQUEST_CHANGES`), and whose reviewed commit equals the PR's current head. tea's exit code alone is never treated as evidence the review landed. -`issue-comment.sh` applies the same fail-closed, boundary-bounded read-back to issue comments: it records the maximum existing comment id _before_ posting via `tea comment`, then re-fetches the issue's comments via the Gitea REST API and requires a comment whose id is strictly greater than that boundary **and** whose body exactly matches what was submitted. Bounding the read-back by the pre-write id is essential — a body-only match across all history would falsely report success if `tea comment` silently no-ops (the #865 bug) while an identically-bodied comment already existed from a prior run. Gitea comment and review ids are monotonic, so `id > boundary` reliably means "created after this write began". +`issue-comment.sh` applies the same fail-closed, boundary-bounded read-back to issue comments: it records the maximum existing comment id _before_ posting via `tea comment`, then re-fetches the issue's comments via the Gitea REST API and requires a comment whose id is strictly greater than that boundary, **whose author login equals the acting identity**, **and** whose body exactly matches what was submitted. Bounding the read-back by the pre-write id is essential — a body-only match across all history would falsely report success if `tea comment` silently no-ops (the #865 bug) while an identically-bodied comment already existed from a prior run. Gitea comment and review ids are monotonic, so `id > boundary` reliably means "created after this write began". + +**Invocation attribution, not just temporal ordering.** `id > boundary` alone only proves a record was created after the write began; it would still be satisfied by a _concurrent_ write from a _different_ identity while this `tea` invocation created nothing. Both wrappers therefore additionally require the accepted record's author login to equal the identity the API token authenticates as, narrowing the match to this invocation's writer. The one residual window — a concurrent write by the _same_ identity with an identical body/state inside the boundary window — cannot be eliminated without a tea-emitted created-record id, which tea 0.11.1 does not reliably provide; it is strictly narrower than temporal-only matching and is documented in-code. + +**Full pagination.** Gitea paginates list endpoints, so a single-page read of the comments or reviews list would false-negative once the freshly created record lands beyond the first page. Both the pre-write boundary computation and the post-write read-back walk every page (`?limit=&page=1,2,…` until a short/empty page) so the match is exhaustive regardless of how many comments or reviews already exist. ## `tea` invocation notes (Gitea) diff --git a/packages/mosaic/framework/tools/git/issue-comment.sh b/packages/mosaic/framework/tools/git/issue-comment.sh index e7d4a11a..64f1a147 100755 --- a/packages/mosaic/framework/tools/git/issue-comment.sh +++ b/packages/mosaic/framework/tools/git/issue-comment.sh @@ -73,8 +73,9 @@ fi detect_platform >/dev/null # Resolve and cache the Gitea REST endpoint + token for the current remote. -# Populates GITEA_API_BASE and GITEA_API_TOKEN. Returns non-zero (with a -# clear stderr message) if any part of the resolution fails. +# Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE (…/api/v1/repos/), +# and GITEA_API_TOKEN. Returns non-zero (with a clear stderr message) if any +# part of the resolution fails. gitea_resolve_api() { local host configured_url repo @@ -91,31 +92,86 @@ gitea_resolve_api() { echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2 return 1 } - GITEA_API_BASE="${configured_url%/}/api/v1/repos/$repo" + GITEA_API_ROOT="${configured_url%/}/api/v1" + GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo" return 0 } -# Print the maximum existing comment id on an issue (0 if none). This is the -# pre-write BOUNDARY: Gitea comment ids are monotonic, so any comment created -# by a subsequent write has an id strictly greater than this value. Bounding -# the read-back this way is what distinguishes a genuine fresh write from a -# pre-existing comment that merely happens to share the same body — the exact -# false-positive a body-only, whole-history match would miss when `tea -# comment` silently no-ops (#865). -gitea_max_comment_id() { - local issue_number="$1" response_file status +# 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 record lands beyond page 1. Walks +# page=1,2,… until a short page (fewer than the requested limit) or an 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 - response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-boundary.XXXXXX") + printf '[]' > "$dest" + while :; do + page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-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 record to THIS invocation's writer so +# a concurrent write 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-issue-comment-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_BASE/issues/$issue_number/comments"); then - echo "Error: Gitea comment boundary read transport failed" >&2 + "$GITEA_API_ROOT/user"); then + echo "Error: Gitea authenticated-identity read transport failed" >&2 return 1 fi if [[ "$status" != "200" ]]; then - echo "Error: Gitea comment boundary read failed with HTTP $status" >&2 + echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2 return 1 fi @@ -123,11 +179,37 @@ gitea_max_comment_id() { 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 comment id on an issue (0 if none). This is the +# pre-write BOUNDARY: Gitea comment ids are monotonic, so any comment created +# by a subsequent write has an id strictly greater than this value. +gitea_max_comment_id() { + local issue_number="$1" merged_file + + merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-boundary.XXXXXX") + trap 'rm -f "$merged_file"' RETURN + + gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1 + + python3 - "$merged_file" <<'PY' +import json +import sys + try: with open(sys.argv[1], encoding="utf-8") as response: comments = json.load(response) - if not isinstance(comments, list): - raise ValueError("response is not a comment list") ids = [c.get("id") for c in comments if isinstance(c, dict) and isinstance(c.get("id"), int)] print(max(ids) if ids else 0) except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: @@ -136,31 +218,29 @@ except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: PY } -# Independently re-fetch the issue's comments via the Gitea REST API and -# require a comment that was created by THIS write: its id must be strictly -# greater than the pre-write boundary AND its body must exactly match what we -# submitted (see header comment: tea's exit code is not trustworthy evidence -# of a durable write on its own). Prints the matched comment ID on success. +# Independently re-fetch (all pages of) the issue's comments and require a +# comment attributable to THIS invocation: id strictly greater than the +# pre-write boundary AND author login equal to the acting identity AND exact +# body match. tea's exit code is not trustworthy evidence of a durable write +# on its own (#865); id-above-boundary alone is only temporal ordering, so the +# author-login check is what excludes a concurrent write by a DIFFERENT +# identity. Prints the matched comment ID on success. +# +# Residual (documented, not eliminable without a tea-emitted created-record +# id, which tea 0.11.1 does not reliably provide): a concurrent write by the +# SAME identity with an identical body inside the boundary window could still +# be accepted. That is a strictly narrower window than temporal-only matching. gitea_verify_comment_posted() { - local issue_number="$1" comment_body="$2" boundary="$3" - local readback_response_file status + local issue_number="$1" comment_body="$2" boundary="$3" acting_login="$4" + local merged_file - readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX") - trap 'rm -f "$readback_response_file"' RETURN + merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX") + trap 'rm -f "$merged_file"' RETURN - if ! status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \ - -H "Authorization: token $GITEA_API_TOKEN" \ - "$GITEA_API_BASE/issues/$issue_number/comments"); then - echo "Error: Gitea comment read-back transport failed" >&2 - return 1 - fi - if [[ "$status" != "200" ]]; then - echo "Error: Gitea comment read-back failed with HTTP $status" >&2 - return 1 - fi + gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1 - EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" \ - python3 - "$readback_response_file" <<'PY' + EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" ACTING_LOGIN="$acting_login" \ + python3 - "$merged_file" <<'PY' import json import os import sys @@ -172,17 +252,22 @@ try: raise ValueError("response is not a comment list") expected_body = os.environ["EXPECTED_COMMENT_BODY"] boundary = int(os.environ["BOUNDARY_COMMENT_ID"]) - # Require both: created-after-boundary (fresh write) AND exact body match. + acting_login = os.environ["ACTING_LOGIN"] + # Attribution to THIS write: created-after-boundary AND authored by the + # acting identity AND exact body match. The author check excludes a + # concurrent DIFFERENT-identity writer that id+body alone would admit. matches = [ c for c in comments if isinstance(c, dict) and isinstance(c.get("id"), int) and c.get("id") > boundary + and (c.get("user") or {}).get("login") == acting_login and c.get("body") == expected_body ] if not matches: raise ValueError( - "no comment created by this write matched (id > boundary and exact body); " + "no comment attributable to this write matched " + "(id > boundary, acting identity, exact body); " "tea may have silently no-opped (#865)" ) comment_id = max(c["id"] for c in matches) @@ -206,9 +291,11 @@ elif [[ "$PLATFORM" == "gitea" ]]; then exit 1 } - # Resolve the REST endpoint and record the pre-write boundary BEFORE the - # write, so the read-back can require a strictly-newer comment id. + # Resolve the REST endpoint, the acting identity, and the pre-write + # boundary BEFORE the write, so the read-back can require a strictly-newer + # comment id authored by this identity. gitea_resolve_api || exit 1 + ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 boundary=$(gitea_max_comment_id "$ISSUE_NUMBER") || exit 1 TEA_ARGS=(comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") @@ -220,7 +307,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then fi tea "${TEA_ARGS[@]}" - comment_id=$(gitea_verify_comment_posted "$ISSUE_NUMBER" "$COMMENT" "$boundary") || { + comment_id=$(gitea_verify_comment_posted "$ISSUE_NUMBER" "$COMMENT" "$boundary" "$ACTING_LOGIN") || { echo "Error: could not verify comment landed on Gitea issue #$ISSUE_NUMBER via bounded read-back; treating tea's exit code as untrustworthy (#865)." >&2 exit 1 } diff --git a/packages/mosaic/framework/tools/git/pr-review.sh b/packages/mosaic/framework/tools/git/pr-review.sh index b57fd43b..0ba60760 100755 --- a/packages/mosaic/framework/tools/git/pr-review.sh +++ b/packages/mosaic/framework/tools/git/pr-review.sh @@ -193,8 +193,9 @@ PY } # Resolve and cache the Gitea REST endpoint + token for the current remote. -# Populates GITEA_API_BASE and GITEA_API_TOKEN. Returns non-zero (with a -# clear stderr message) on any resolution failure. +# 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 @@ -211,31 +212,85 @@ gitea_resolve_api() { echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2 return 1 } - GITEA_API_BASE="${configured_url%/}/api/v1/repos/$repo" + GITEA_API_ROOT="${configured_url%/}/api/v1" + GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo" return 0 } -# 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. Bounding the read-back this way is what turns the -# check into a genuine write-verification rather than a match against any -# historical review — the same never-trust-exit-zero discipline #865 requires -# for comments, applied to the review STATE itself. -gitea_max_review_id() { - local pr_number="$1" response_file status +# 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 - response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-boundary.XXXXXX") + 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_BASE/pulls/$pr_number/reviews"); then - echo "Error: Gitea review boundary read transport failed" >&2 + "$GITEA_API_ROOT/user"); then + echo "Error: Gitea authenticated-identity read transport failed" >&2 return 1 fi if [[ "$status" != "200" ]]; then - echo "Error: Gitea review boundary read failed with HTTP $status" >&2 + echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2 return 1 fi @@ -243,11 +298,39 @@ gitea_max_review_id() { 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) - if not isinstance(reviews, list): - raise ValueError("response is not a review list") 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: @@ -258,21 +341,32 @@ 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 that was created by THIS action: its id must be strictly greater -# than the pre-write boundary, its state must equal the expected state -# (APPROVED / REQUEST_CHANGES), and it must have been submitted against the -# PR's current head commit. Prints the matched review id on success; fails -# closed (non-zero, clear stderr) if no such review is found. +# 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. +# $3 = pre-write boundary review id, $4 = acting reviewer login. gitea_verify_review_submitted() { - local pr_number="$1" expected_state="$2" boundary="$3" - local pr_response_file reviews_response_file status head_sha review_id + 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_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-state.XXXXXX") - trap 'rm -f "$pr_response_file" "$reviews_response_file"' RETURN + 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}' \ @@ -302,19 +396,10 @@ print(head_sha) PY ) || return 1 - if ! status=$(curl -sS -o "$reviews_response_file" -w '%{http_code}' \ - -H "Authorization: token $GITEA_API_TOKEN" \ - "$GITEA_API_BASE/pulls/$pr_number/reviews"); then - echo "Error: Gitea review read-back transport failed" >&2 - return 1 - fi - if [[ "$status" != "200" ]]; then - echo "Error: Gitea review read-back failed with HTTP $status" >&2 - return 1 - fi + 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" \ - python3 - "$reviews_response_file" <<'PY' + 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 @@ -327,22 +412,24 @@ try: expected_state = os.environ["EXPECTED_STATE"] boundary = int(os.environ["BOUNDARY_REVIEW_ID"]) expected_head = os.environ["EXPECTED_HEAD_SHA"] - # Require all of: created-after-boundary (this action's write), the - # expected review state, and pinned to the PR's current head commit. - # The monotonic id boundary is what proves "submitted by this action" - # rather than matching some pre-existing historical review. + 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 created by this action found " - "(id > boundary, expected state, current head); " + 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) @@ -395,6 +482,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then # 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). @@ -406,7 +494,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then TEA_ARGS+=(--login "$LOGIN_OVERRIDE") fi tea "${TEA_ARGS[@]}" - review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "APPROVED" "$review_boundary") || { + 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 } @@ -427,6 +515,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then # 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). @@ -438,7 +527,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then TEA_ARGS+=(--login "$LOGIN_OVERRIDE") fi tea "${TEA_ARGS[@]}" - review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "REQUEST_CHANGES" "$review_boundary") || { + 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 } diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh index 8dd8a06b..b036b8e4 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh @@ -1,23 +1,28 @@ #!/usr/bin/env bash # Regression harness for issue-comment.sh top-level `tea comment` invocation and -# its BOUNDED read-back verification (#865). +# its BOUNDED, INVOCATION-ATTRIBUTED, PAGINATED read-back verification (#865). # # The #865 bug: `tea issue comment ...` (a nonexistent subcommand on tea # v0.11.1) silently no-ops and exits 0, so a comment is never posted. A naive # read-back that matches ANY historical comment by body would falsely report # success whenever an identically-bodied comment already exists from a prior -# run. This harness proves the wrapper: +# run. Merely bounding by "id > pre-write max" is also insufficient: it accepts +# ANY newer matching comment, including one a CONCURRENT DIFFERENT identity +# posted while this tea invocation created nothing. This harness proves the +# wrapper: # 1. uses the top-level `tea comment` form (never `tea issue comment`); # 2. records the pre-write maximum comment id as a boundary and requires a # strictly-newer comment on read-back, so a pre-existing identical body # does NOT satisfy verification (fails closed); -# 3. reports success only when a genuinely new comment (id > boundary) with -# the exact body appears. +# 3. attributes the matched comment to the acting identity (GET /user login), +# so a concurrent DIFFERENT-identity write does NOT satisfy verification; +# 4. reports success only when a genuinely new comment (id > boundary) with +# the exact body AND the acting author appears. # # The `tea` stub NEVER creates a comment (it mimics the silent no-op); the # "server" comment state is modeled entirely by the curl stub's responses, so # the fresh-success vs. no-op distinction is driven purely by whether the -# post-write read-back surfaces a new id. +# post-write read-back surfaces a new, correctly-attributed id. set -euo pipefail @@ -42,7 +47,10 @@ git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/sta ISSUE_NUMBER=7 API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" +API_ROOT="https://git.mosaicstack.dev/api/v1" BODY='durable "note" -- marker' +ACTING_LOGIN="primary-reviewer" +FOREIGN_LOGIN="other-writer" CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY' import json @@ -91,8 +99,9 @@ exit 92 SH chmod +x "$BIN_DIR/tea" -# curl stub: serves GET .../issues/7/comments. First call = pre-write boundary, -# second call = post-write read-back. The boundary always contains a +# curl stub: serves GET /user (acting identity) and GET .../issues/7/comments +# (paginated: ?limit=&page=). The comments endpoint's first call = pre-write +# boundary, second call = post-write read-back. The boundary always contains a # pre-existing comment (id 50) whose body is IDENTICAL to the one under test, # which is exactly the condition a body-only match would trip over. cat > "$BIN_DIR/curl" <<'SH' @@ -114,6 +123,10 @@ while [[ $# -gt 0 ]]; do esac done +# Strip any query string so pagination params don't defeat path matching, but +# still log the full URL (including ?limit=&page=) so the test can assert the +# read-back paginated. +path="${url%%\?*}" printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG" write_response() { @@ -123,41 +136,58 @@ write_response() { printf '%s' "$status" } -if [[ "$method" == "GET" && "$url" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then +if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then + write_response 200 "$(ISSUE_COMMENT_LOGIN="$ISSUE_COMMENT_ACTING_LOGIN" python3 - <<'PY' +import json +import os +print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]})) +PY +)" +elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then calls_file="$ISSUE_COMMENT_CALLS" if [[ -f "$calls_file" ]]; then # post-write read-back - if [[ "$ISSUE_COMMENT_TEST_MODE" == "fresh-success" ]]; then - response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" python3 - <<'PY' + response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" \ + ISSUE_COMMENT_ACTING_LOGIN="$ISSUE_COMMENT_ACTING_LOGIN" \ + ISSUE_COMMENT_FOREIGN_LOGIN="$ISSUE_COMMENT_FOREIGN_LOGIN" \ + ISSUE_COMMENT_TEST_MODE="$ISSUE_COMMENT_TEST_MODE" python3 - <<'PY' import json import os body = os.environ["ISSUE_COMMENT_BODY"] -print(json.dumps([ - {"id": 50, "body": body}, - {"id": 60, "body": body}, -])) -PY -) - else - # no-op: nothing new landed; the pre-existing id-50 comment remains. - response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" python3 - <<'PY' -import json -import os +acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"] +foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"] +mode = os.environ["ISSUE_COMMENT_TEST_MODE"] -body = os.environ["ISSUE_COMMENT_BODY"] -print(json.dumps([{"id": 50, "body": body}])) +if mode == "fresh-success": + # A genuinely new comment (id 60 > boundary 50) authored by the acting + # identity. + records = [ + {"id": 50, "body": body, "user": {"login": acting}}, + {"id": 60, "body": body, "user": {"login": acting}}, + ] +elif mode == "foreign-identity": + # A concurrent new comment (id 60 > boundary 50) with the SAME body but a + # DIFFERENT author. tea created nothing; attribution must reject this. + records = [ + {"id": 50, "body": body, "user": {"login": acting}}, + {"id": 60, "body": body, "user": {"login": foreign}}, + ] +else: + # no-op: nothing new landed; the pre-existing id-50 comment remains. + records = [{"id": 50, "body": body, "user": {"login": acting}}] +print(json.dumps(records)) PY ) - fi else : > "$calls_file" - response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" python3 - <<'PY' + response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" \ + ISSUE_COMMENT_ACTING_LOGIN="$ISSUE_COMMENT_ACTING_LOGIN" python3 - <<'PY' import json import os - body = os.environ["ISSUE_COMMENT_BODY"] -print(json.dumps([{"id": 50, "body": body}])) +acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"] +print(json.dumps([{"id": 50, "body": body, "user": {"login": acting}}])) PY ) fi @@ -184,7 +214,10 @@ run_comment() { ISSUE_COMMENT_CALLS="$CALLS_FILE" \ ISSUE_COMMENT_TEST_MODE="$mode" \ ISSUE_COMMENT_EXPECTED_BODY="$BODY" \ + ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \ + ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \ ISSUE_COMMENT_API_BASE="$API_BASE" \ + ISSUE_COMMENT_API_ROOT="$API_ROOT" \ "$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" ) > "$OUTPUT_FILE" 2>&1 } @@ -206,11 +239,27 @@ if grep -q '^issue comment' "$TEA_LOG"; then echo "FAIL: wrapper used the broken 'tea issue comment' subcommand" >&2 exit 1 fi -[[ "$(grep -c "^GET $API_BASE/issues/7/comments$" "$CURL_LOG")" == "2" ]] +[[ "$(grep -c "^GET $API_BASE/issues/7/comments?" "$CURL_LOG")" == "2" ]] +# Read-back must be paginated (limit + page query params present). +grep -q "^GET $API_BASE/issues/7/comments?limit=[0-9]*&page=1$" "$CURL_LOG" +# Attribution must have resolved the acting identity via GET /user. +grep -q "^GET $API_ROOT/user$" "$CURL_LOG" -# Case 2: a genuinely new comment (id 60 > boundary 50) verifies successfully. +# Case 2: a concurrent DIFFERENT-identity write (id 60 > boundary, same body, +# foreign author) must FAIL CLOSED — temporal ordering is not attribution. +if run_comment foreign-identity; then + echo "FAIL: wrapper accepted a concurrent comment authored by a different identity" >&2 + cat "$OUTPUT_FILE" >&2 + exit 1 +fi +if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then + echo "FAIL: read-back matched a different-identity comment (attribution bypassed)" >&2 + exit 1 +fi + +# Case 3: a genuinely new comment (id 60 > boundary 50, acting author) verifies. run_comment fresh-success grep -q 'Added and verified comment on Gitea issue #7 (comment ID 60)' "$OUTPUT_FILE" grep -q "^comment 7 " "$TEA_LOG" -echo "issue-comment.sh bounded read-back regression passed" +echo "issue-comment.sh bounded + attributed read-back regression passed" 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 bf32ba4d..c28310c3 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 @@ -23,6 +23,9 @@ cleanup() { } trap cleanup EXIT +ACTING_LOGIN="review-bot" +FOREIGN_LOGIN="other-writer" + mkdir -p "$REPO_DIR" "$BIN_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git @@ -67,7 +70,7 @@ if [[ "$*" == *" -comment "* || "$*" == *" --comment "* || "$*" == *" -comment" fi case "${PR_REVIEW_TEST_MODE:-}" in - approve) + approve|paginated-approve|foreign-review) [[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90 ;; request-changes) @@ -127,6 +130,12 @@ while [[ $# -gt 0 ]]; do esac done +# Strip any query string so pagination params (?limit=&page=) don't defeat +# path matching, but keep the full URL in the log so tests can assert that the +# read-back paginated. +path="${url%%\?*}" +page="${url##*page=}" +[[ "$page" == "$url" ]] && page=1 printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG" write_response() { @@ -144,32 +153,83 @@ case "${PR_REVIEW_TEST_MODE:-}" in write-http-failure) write_response 500 '{"message":"simulated rejection"}' ;; - approve|request-changes|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|readback-failure) - if [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then - # First GET = pre-write review-id BOUNDARY; second GET = post-write - # read-back that must surface a strictly-newer review created by - # THIS action (id 200 > boundary 100), with the expected state and - # pinned to the PR's current head commit. + approve|request-changes|paginated-approve|foreign-review|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|readback-failure) + if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then + # Acting reviewer identity used for invocation attribution. + write_response 200 "$(PR_REVIEW_LOGIN="$PR_REVIEW_ACTING_LOGIN" python3 - <<'PY' +import json +import os +print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]})) +PY +)" + elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then + # First (boundary) call precedes the write; later calls are the + # post-write read-back that must surface a strictly-newer review + # created by THIS action (id 200 > boundary 100), authored by the + # acting identity, with the expected state and pinned to the PR's + # current head commit. The list is served PAGINATED so a target + # beyond page 1 is only found by a fully-paginating read-back. calls_file="${PR_REVIEW_REVIEW_CALLS:-/dev/null}" if [[ -f "$calls_file" ]]; then - state="APPROVED" - [[ "$PR_REVIEW_TEST_MODE" == "request-changes" ]] && state="REQUEST_CHANGES" - response=$(PR_REVIEW_STATE="$state" python3 - <<'PY' + phase="post" + else + : > "$calls_file" + phase="boundary" + fi + state="APPROVED" + [[ "$PR_REVIEW_TEST_MODE" == "request-changes" ]] && state="REQUEST_CHANGES" + response=$(PR_REVIEW_PHASE="$phase" PR_REVIEW_PAGE="$page" \ + PR_REVIEW_MODE="$PR_REVIEW_TEST_MODE" PR_REVIEW_STATE="$state" \ + PR_REVIEW_ACTING_LOGIN="$PR_REVIEW_ACTING_LOGIN" \ + PR_REVIEW_FOREIGN_LOGIN="$PR_REVIEW_FOREIGN_LOGIN" python3 - <<'PY' import json import os -print(json.dumps([ - {"id": 100, "state": "COMMENT", "commit_id": "oldsha0000"}, - {"id": 200, "state": os.environ["PR_REVIEW_STATE"], "commit_id": "HEADSHA_FEEDFACE"}, -])) +phase = os.environ["PR_REVIEW_PHASE"] +page = int(os.environ["PR_REVIEW_PAGE"]) +mode = os.environ["PR_REVIEW_MODE"] +state = os.environ["PR_REVIEW_STATE"] +acting = os.environ["PR_REVIEW_ACTING_LOGIN"] +foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"] + + +def review(review_id, review_state, commit, login): + return { + "id": review_id, + "state": review_state, + "commit_id": commit, + "user": {"login": login}, + } + + +if phase == "boundary": + records = [review(100, "COMMENT", "oldsha0000", acting)] if page == 1 else [] +elif mode == "paginated-approve": + # A full first page (50 non-matching records) forces the read-back to + # request page 2, where the genuine matching review lives. + if page == 1: + records = [review(101 + i, "COMMENT", "oldsha0000", acting) for i in range(50)] + elif page == 2: + records = [review(200, state, "HEADSHA_FEEDFACE", acting)] + else: + records = [] +elif mode == "foreign-review": + # A concurrent APPROVED review at the current head, but authored by a + # DIFFERENT identity. tea created nothing; attribution must reject this. + records = [review(200, state, "HEADSHA_FEEDFACE", foreign)] if page == 1 else [] +else: + if page == 1: + records = [ + review(100, "COMMENT", "oldsha0000", acting), + review(200, state, "HEADSHA_FEEDFACE", acting), + ] + else: + records = [] +print(json.dumps(records)) PY ) - else - : > "$calls_file" - response='[{"id":100,"state":"COMMENT","commit_id":"oldsha0000"}]' - fi write_response 200 "$response" - elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then + elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then write_response 200 '{"head":{"sha":"HEADSHA_FEEDFACE"}}' elif [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY' @@ -222,6 +282,7 @@ run_review() { local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}" local expected_repo="${6:-mosaicstack/stack}" local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo" + local expected_api_root="${configured_url%/}/api/v1" git -C "$REPO_DIR" remote set-url origin "$remote_url" write_credentials "$configured_url" : > "$TEA_LOG" @@ -238,6 +299,9 @@ run_review() { PR_REVIEW_TEST_MODE="$mode" \ PR_REVIEW_EXPECTED_BODY="$comment" \ PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \ + PR_REVIEW_API_ROOT="$expected_api_root" \ + PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \ + PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \ "$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ) > "$OUTPUT_FILE" 2>&1 } @@ -246,14 +310,36 @@ run_review approve approve grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG" grep -q 'Approved and verified Gitea PR #123 (review ID 200)' "$OUTPUT_FILE" # #865: the approval STATE itself is read back — a pre-write boundary GET and a -# post-write read-back GET on the reviews endpoint, plus a PR head lookup. -grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG" +# post-write read-back GET on the (paginated) reviews endpoint, plus a PR head +# lookup and an acting-identity (GET /user) resolution for attribution. +grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews?limit=[0-9]*&page=1$' "$CURL_LOG" grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG" +grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG" if grep -q 'comment' "$TEA_LOG"; then echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2 exit 1 fi +# #865 (invocation attribution): a concurrent APPROVED review at the current +# head, authored by a DIFFERENT identity while tea created nothing, must NOT +# satisfy verification — id-above-boundary + state + head is only temporal +# ordering, not proof THIS reviewer wrote it. +if run_review foreign-review approve; then + echo "FAIL: approve accepted a review authored by a different identity" >&2 + cat "$OUTPUT_FILE" >&2 + exit 1 +fi +if grep -q 'Approved and verified' "$OUTPUT_FILE"; then + echo "FAIL: read-back matched a different-identity review (attribution bypassed)" >&2 + exit 1 +fi + +# #865 (pagination): a genuine matching review that lands beyond page 1 of the +# reviews list must still be found by a fully-paginating read-back. +run_review paginated-approve approve +grep -q 'Approved and verified Gitea PR #123 (review ID 200)' "$OUTPUT_FILE" +grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews?limit=[0-9]*&page=2$' "$CURL_LOG" + # #835: tea v0.11.1 defines no --comment/-comment flag on `pr approve`. A # review body supplied alongside approve must be routed through the durable # comment REST API instead of being passed to `tea` directly. @@ -268,7 +354,7 @@ grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$ run_review request-changes request-changes changes-required grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG" grep -q 'Requested changes and verified on Gitea PR #123 (review ID 200)' "$OUTPUT_FILE" -grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG" +grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews?limit=[0-9]*&page=1$' "$CURL_LOG" grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG" grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG" grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"