fix(tools): bound Gitea read-back to this write; verify approve/reject state (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

Review remediation for two correctness holes in the #865 fix:

BLOCKER 1 — issue-comment.sh read-back was body-only across all history:
if `tea comment` silently no-opped (the #865 bug) while an identically
bodied comment already existed from a prior run, the read-back matched the
OLD comment and falsely reported success. Now record the pre-write maximum
comment id as a boundary and require a comment with id > boundary AND exact
body match; monotonic Gitea ids make id > boundary mean "created by this
write". Fails closed otherwise.

BLOCKER 2 — pr-review.sh approve/reject trusted tea's exit code for the
review STATE (same never-trust-exit-zero defect class as #865). Removed the
TODO deferral and added a real bounded read-back: record the max review id
before `tea pr approve`/`reject`, then require a review with id > boundary,
the expected state (APPROVED / REQUEST_CHANGES), and commit_id equal to the
PR's current head. Fails closed if absent.

Tests: extended test-pr-review-gitea-comment.sh to model and assert the new
review-state read-back (guardrails preserved, assertions added). Added
test-issue-comment-readback.sh proving the pre-existing-identical-body
false positive now fails closed and a genuinely new comment verifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-07-21 18:21:02 -05:00
parent a27f1fa7df
commit 10fdd49e32
5 changed files with 529 additions and 43 deletions

View File

@@ -4,11 +4,11 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
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.
`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.
`issue-comment.sh` applies the same fail-closed read-back verification to issue comments: after posting via `tea comment`, it independently re-fetches the issue's comments via the Gitea REST API and confirms one matches the submitted body before reporting success.
`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".
## `tea` invocation notes (Gitea)

View File

@@ -72,16 +72,14 @@ fi
detect_platform >/dev/null
# Independently re-fetch the issue's comments via the Gitea REST API and
# confirm one matches the body we just posted (see header comment: tea's
# exit code is not trustworthy evidence of a durable write on its own).
# Prints the matched comment ID to stdout on success.
gitea_verify_comment_posted() {
local issue_number="$1" comment_body="$2"
local host token configured_url repo api_base readback_response_file
# 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.
gitea_resolve_api() {
local host configured_url repo
host=$(get_remote_host)
token=$(get_gitea_token "$host") || {
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for comment read-back verification" >&2
return 1
}
@@ -93,23 +91,76 @@ gitea_verify_comment_posted() {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
api_base="${configured_url%/}/api/v1/repos/$repo"
GITEA_API_BASE="${configured_url%/}/api/v1/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
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-boundary.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
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea comment boundary 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:
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:
print(f"Error: could not compute Gitea comment boundary: {error}", file=sys.stderr)
raise SystemExit(1)
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.
gitea_verify_comment_posted() {
local issue_number="$1" comment_body="$2" boundary="$3"
local readback_response_file status
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
trap 'rm -f "$readback_response_file"' RETURN
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
-H "Authorization: token $token" \
"$api_base/issues/$issue_number/comments"); then
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 [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
if [[ "$status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $status" >&2
return 1
fi
EXPECTED_COMMENT_BODY="$comment_body" python3 - "$readback_response_file" <<'PY'
EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" \
python3 - "$readback_response_file" <<'PY'
import json
import os
import sys
@@ -120,13 +171,21 @@ try:
if not isinstance(comments, list):
raise ValueError("response is not a comment list")
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
matches = [c for c in comments if isinstance(c, dict) and c.get("body") == expected_body]
boundary = int(os.environ["BOUNDARY_COMMENT_ID"])
# Require both: created-after-boundary (fresh write) AND exact body match.
matches = [
c for c in comments
if isinstance(c, dict)
and isinstance(c.get("id"), int)
and c.get("id") > boundary
and c.get("body") == expected_body
]
if not matches:
raise ValueError("no matching comment found on read-back")
best = max(matches, key=lambda c: c.get("id") or 0)
comment_id = best.get("id")
if not isinstance(comment_id, int) or comment_id <= 0:
raise ValueError("matching comment has no usable id")
raise ValueError(
"no comment created by this write matched (id > boundary and exact body); "
"tea may have silently no-opped (#865)"
)
comment_id = max(c["id"] for c in matches)
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
@@ -146,6 +205,12 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
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.
gitea_resolve_api || exit 1
boundary=$(gitea_max_comment_id "$ISSUE_NUMBER") || exit 1
TEA_ARGS=(comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
# --login override goes LAST: tea honors only the final --login on its
# command line, so an override placed before the detected default above
@@ -155,8 +220,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
fi
tea "${TEA_ARGS[@]}"
comment_id=$(gitea_verify_comment_posted "$ISSUE_NUMBER" "$COMMENT") || {
echo "Error: could not verify comment landed on Gitea issue #$ISSUE_NUMBER via read-back; treating tea's exit code as untrustworthy (#865)." >&2
comment_id=$(gitea_verify_comment_posted "$ISSUE_NUMBER" "$COMMENT" "$boundary") || {
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
}
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"

View File

@@ -192,6 +192,171 @@ PY
return 0
}
# 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.
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_BASE="${configured_url%/}/api/v1/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
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-boundary.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
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea review boundary 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:
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:
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 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.
#
# Args: $1 = PR number, $2 = expected state (APPROVED|REQUEST_CHANGES),
# $3 = pre-write boundary review id.
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
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
# 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
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
review_id=$(EXPECTED_STATE="$expected_state" BOUNDARY_REVIEW_ID="$boundary" EXPECTED_HEAD_SHA="$head_sha" \
python3 - "$reviews_response_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"]
# 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.
matches = [
r for r in reviews
if isinstance(r, dict)
and isinstance(r.get("id"), int)
and r.get("id") > boundary
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); "
"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)
@@ -225,6 +390,12 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
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
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")
@@ -235,14 +406,11 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
fi
tea "${TEA_ARGS[@]}"
echo "Approved Gitea PR #$PR_NUMBER"
# TODO(#865): this trusts tea's exit code for the approval STATE
# itself (no read-back of the review's approved status via the
# Gitea REST API). Only the optional accompanying COMMENT text
# below is independently read-back verified. Add a review-state
# read-back (e.g. GET /repos/{repo}/pulls/{pr}/reviews) if the
# approval state itself needs the same durable-provenance
# guarantee as comments.
review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "APPROVED" "$review_boundary") || {
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)"
@@ -256,6 +424,10 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
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
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")
@@ -266,11 +438,11 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
fi
tea "${TEA_ARGS[@]}"
echo "Requested changes on Gitea PR #$PR_NUMBER"
# TODO(#865): this trusts tea's exit code for the rejection STATE
# itself (no read-back of the review's rejected/changes-requested
# status via the Gitea REST API). Only the required accompanying
# COMMENT text below is independently read-back verified.
review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "REQUEST_CHANGES" "$review_boundary") || {
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)"
;;

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# Regression harness for issue-comment.sh top-level `tea comment` invocation and
# its BOUNDED 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:
# 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.
#
# 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.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
CALLS_FILE="$WORK_DIR/comment_calls"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
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
ISSUE_NUMBER=7
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
BODY='durable "note" -- marker'
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: resolves the login list, and treats `tea comment ...` as a silent
# no-op (exit 0 without creating anything) to mimic the real failure mode.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
# The wrapper must use the TOP-LEVEL `tea comment` form; the broken
# `tea issue comment` subcommand must never be invoked.
if [[ "$*" == issue\ comment* ]]; then
echo "wrapper invoked nonexistent 'tea issue comment' subcommand" >&2
exit 90
fi
if [[ "$*" == comment\ * ]]; then
# Mimic tea v0.11.1: exit 0. Whether a comment actually lands is modeled
# by the curl stub's post-write read-back response, not here.
exit 0
fi
echo "Unexpected tea command: $*" >&2
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
# 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'
#!/usr/bin/env bash
set -euo pipefail
output_file=""
method="GET"
url=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-w|-H) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
if [[ "$method" == "GET" && "$url" == "$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'
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
body = os.environ["ISSUE_COMMENT_BODY"]
print(json.dumps([{"id": 50, "body": body}]))
PY
)
fi
else
: > "$calls_file"
response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" python3 - <<'PY'
import json
import os
body = os.environ["ISSUE_COMMENT_BODY"]
print(json.dumps([{"id": 50, "body": body}]))
PY
)
fi
write_response 200 "$response"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
run_comment() {
local mode="$1"
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$OUTPUT_FILE"
rm -f "$CALLS_FILE"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CALLS="$CALLS_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_EXPECTED_BODY="$BODY" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY"
) > "$OUTPUT_FILE" 2>&1
}
# Case 1: silent no-op with a pre-existing identical body must FAIL CLOSED.
if run_comment noop-preexisting; then
echo "FAIL: wrapper reported success when tea no-opped but an identical body pre-existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: read-back matched a pre-existing comment by body only" >&2
exit 1
fi
# The wrapper must have used the top-level form and read comments back twice
# (boundary + post-write).
grep -q "^comment 7 " "$TEA_LOG"
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" ]]
# Case 2: a genuinely new comment (id 60 > boundary 50) verifies successfully.
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"

View File

@@ -145,7 +145,33 @@ case "${PR_REVIEW_TEST_MODE:-}" in
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" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
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.
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'
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"},
]))
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
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'
import json
import os
@@ -201,12 +227,14 @@ run_review() {
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$OUTPUT_FILE"
rm -f "$WORK_DIR/review_calls"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \
PR_REVIEW_CURL_LOG="$CURL_LOG" \
PR_REVIEW_REVIEW_CALLS="$WORK_DIR/review_calls" \
PR_REVIEW_TEST_MODE="$mode" \
PR_REVIEW_EXPECTED_BODY="$comment" \
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
@@ -216,7 +244,11 @@ run_review() {
run_review approve approve
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
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"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG"
if grep -q 'comment' "$TEA_LOG"; then
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
exit 1
@@ -227,7 +259,7 @@ fi
# comment REST API instead of being passed to `tea` directly.
run_review approve approve approve-note
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
grep -q 'Approved and verified Gitea PR #123 (review ID 200)' "$OUTPUT_FILE"
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"
@@ -235,7 +267,8 @@ grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$
# #835: same for `pr reject` (request-changes), where a comment is required.
run_review request-changes request-changes changes-required
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
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 '^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"