fix(tools): attribute read-back to acting identity and paginate fully (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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)
|
## `tea` invocation notes (Gitea)
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ fi
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote.
|
# 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
|
# Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE (…/api/v1/repos/<slug>),
|
||||||
# clear stderr message) if any part of the resolution fails.
|
# and GITEA_API_TOKEN. Returns non-zero (with a clear stderr message) if any
|
||||||
|
# part of the resolution fails.
|
||||||
gitea_resolve_api() {
|
gitea_resolve_api() {
|
||||||
local host configured_url repo
|
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
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
return 1
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Print the maximum existing comment id on an issue (0 if none). This is the
|
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||||
# pre-write BOUNDARY: Gitea comment ids are monotonic, so any comment created
|
# array). Gitea paginates list responses, so a single-page read would
|
||||||
# by a subsequent write has an id strictly greater than this value. Bounding
|
# false-negative once a newly created record lands beyond page 1. Walks
|
||||||
# the read-back this way is what distinguishes a genuine fresh write from a
|
# page=1,2,… until a short page (fewer than the requested limit) or an empty
|
||||||
# pre-existing comment that merely happens to share the same body — the exact
|
# page is returned, so the merged array is exhaustive. $1 is the endpoint URL
|
||||||
# false-positive a body-only, whole-history match would miss when `tea
|
# with NO query string. Returns non-zero (clear stderr) on any transport /
|
||||||
# comment` silently no-ops (#865).
|
# HTTP / parse failure.
|
||||||
gitea_max_comment_id() {
|
gitea_fetch_all() {
|
||||||
local issue_number="$1" response_file status
|
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
|
trap 'rm -f "$response_file"' RETURN
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
"$GITEA_API_ROOT/user"); then
|
||||||
echo "Error: Gitea comment boundary read transport failed" >&2
|
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -123,11 +179,37 @@ gitea_max_comment_id() {
|
|||||||
import json
|
import json
|
||||||
import sys
|
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:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
comments = json.load(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)]
|
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)
|
print(max(ids) if ids else 0)
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||||
@@ -136,31 +218,29 @@ except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
# Independently re-fetch the issue's comments via the Gitea REST API and
|
# Independently re-fetch (all pages of) the issue's comments and require a
|
||||||
# require a comment that was created by THIS write: its id must be strictly
|
# comment attributable to THIS invocation: id strictly greater than the
|
||||||
# greater than the pre-write boundary AND its body must exactly match what we
|
# pre-write boundary AND author login equal to the acting identity AND exact
|
||||||
# submitted (see header comment: tea's exit code is not trustworthy evidence
|
# body match. tea's exit code is not trustworthy evidence of a durable write
|
||||||
# of a durable write on its own). Prints the matched comment ID on success.
|
# 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() {
|
gitea_verify_comment_posted() {
|
||||||
local issue_number="$1" comment_body="$2" boundary="$3"
|
local issue_number="$1" comment_body="$2" boundary="$3" acting_login="$4"
|
||||||
local readback_response_file status
|
local merged_file
|
||||||
|
|
||||||
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
|
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
|
||||||
trap 'rm -f "$readback_response_file"' RETURN
|
trap 'rm -f "$merged_file"' RETURN
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
|
gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1
|
||||||
-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
|
|
||||||
|
|
||||||
EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" \
|
EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" ACTING_LOGIN="$acting_login" \
|
||||||
python3 - "$readback_response_file" <<'PY'
|
python3 - "$merged_file" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -172,17 +252,22 @@ try:
|
|||||||
raise ValueError("response is not a comment list")
|
raise ValueError("response is not a comment list")
|
||||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||||
boundary = int(os.environ["BOUNDARY_COMMENT_ID"])
|
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 = [
|
matches = [
|
||||||
c for c in comments
|
c for c in comments
|
||||||
if isinstance(c, dict)
|
if isinstance(c, dict)
|
||||||
and isinstance(c.get("id"), int)
|
and isinstance(c.get("id"), int)
|
||||||
and c.get("id") > boundary
|
and c.get("id") > boundary
|
||||||
|
and (c.get("user") or {}).get("login") == acting_login
|
||||||
and c.get("body") == expected_body
|
and c.get("body") == expected_body
|
||||||
]
|
]
|
||||||
if not matches:
|
if not matches:
|
||||||
raise ValueError(
|
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)"
|
"tea may have silently no-opped (#865)"
|
||||||
)
|
)
|
||||||
comment_id = max(c["id"] for c in matches)
|
comment_id = max(c["id"] for c in matches)
|
||||||
@@ -206,9 +291,11 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve the REST endpoint and record the pre-write boundary BEFORE the
|
# Resolve the REST endpoint, the acting identity, and the pre-write
|
||||||
# write, so the read-back can require a strictly-newer comment id.
|
# boundary BEFORE the write, so the read-back can require a strictly-newer
|
||||||
|
# comment id authored by this identity.
|
||||||
gitea_resolve_api || exit 1
|
gitea_resolve_api || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
boundary=$(gitea_max_comment_id "$ISSUE_NUMBER") || exit 1
|
boundary=$(gitea_max_comment_id "$ISSUE_NUMBER") || exit 1
|
||||||
|
|
||||||
TEA_ARGS=(comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
TEA_ARGS=(comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
@@ -220,7 +307,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
fi
|
fi
|
||||||
tea "${TEA_ARGS[@]}"
|
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
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,8 +193,9 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote.
|
# 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
|
# Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE (…/api/v1/repos/<slug>),
|
||||||
# clear stderr message) on any resolution failure.
|
# and GITEA_API_TOKEN. Returns non-zero (with a clear stderr message) on any
|
||||||
|
# resolution failure.
|
||||||
gitea_resolve_api() {
|
gitea_resolve_api() {
|
||||||
local host configured_url repo
|
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
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
return 1
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Print the maximum existing review id on a PR (0 if none). This is the
|
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||||
# pre-write BOUNDARY: Gitea pull-review ids are monotonic, so any review
|
# array). Gitea paginates list responses, so a single-page read would
|
||||||
# submitted by a subsequent `tea pr approve`/`reject` has an id strictly
|
# false-negative once a newly created review/comment lands beyond page 1.
|
||||||
# greater than this value. Bounding the read-back this way is what turns the
|
# Walks page=1,2,… until a short or empty page is returned so the merged array
|
||||||
# check into a genuine write-verification rather than a match against any
|
# is exhaustive. $1 is the endpoint URL with NO query string. Returns non-zero
|
||||||
# historical review — the same never-trust-exit-zero discipline #865 requires
|
# (clear stderr) on any transport / HTTP / parse failure.
|
||||||
# for comments, applied to the review STATE itself.
|
gitea_fetch_all() {
|
||||||
gitea_max_review_id() {
|
local base_url="$1" dest="$2" page=1 limit=50 status page_file count
|
||||||
local pr_number="$1" response_file status
|
|
||||||
|
|
||||||
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
|
trap 'rm -f "$response_file"' RETURN
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
|
"$GITEA_API_ROOT/user"); then
|
||||||
echo "Error: Gitea review boundary read transport failed" >&2
|
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -243,11 +298,39 @@ gitea_max_review_id() {
|
|||||||
import json
|
import json
|
||||||
import sys
|
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:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as response:
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
reviews = json.load(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)]
|
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)
|
print(max(ids) if ids else 0)
|
||||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||||
@@ -258,21 +341,32 @@ PY
|
|||||||
|
|
||||||
# Independently verify that `tea pr approve`/`reject` produced a durable review
|
# Independently verify that `tea pr approve`/`reject` produced a durable review
|
||||||
# record — never trust tea's exit code alone (#865, same defect class). Require
|
# 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
|
# a review attributable to THIS action: its id must be strictly greater than
|
||||||
# than the pre-write boundary, its state must equal the expected state
|
# the pre-write boundary, its author login must equal the acting identity, its
|
||||||
# (APPROVED / REQUEST_CHANGES), and it must have been submitted against the
|
# state must equal the expected state (APPROVED / REQUEST_CHANGES), and it must
|
||||||
# PR's current head commit. Prints the matched review id on success; fails
|
# have been submitted against the PR's current head commit. The reviews list is
|
||||||
# closed (non-zero, clear stderr) if no such review is found.
|
# 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),
|
# 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() {
|
gitea_verify_review_submitted() {
|
||||||
local pr_number="$1" expected_state="$2" boundary="$3"
|
local pr_number="$1" expected_state="$2" boundary="$3" acting_login="$4"
|
||||||
local pr_response_file reviews_response_file status head_sha review_id
|
local pr_response_file reviews_merged_file status head_sha review_id
|
||||||
|
|
||||||
pr_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
pr_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
||||||
reviews_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-state.XXXXXX")
|
reviews_merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-state.XXXXXX")
|
||||||
trap 'rm -f "$pr_response_file" "$reviews_response_file"' RETURN
|
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.
|
# 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}' \
|
if ! status=$(curl -sS -o "$pr_response_file" -w '%{http_code}' \
|
||||||
@@ -302,19 +396,10 @@ print(head_sha)
|
|||||||
PY
|
PY
|
||||||
) || return 1
|
) || return 1
|
||||||
|
|
||||||
if ! status=$(curl -sS -o "$reviews_response_file" -w '%{http_code}' \
|
gitea_fetch_all "$GITEA_API_BASE/pulls/$pr_number/reviews" "$reviews_merged_file" || return 1
|
||||||
-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" \
|
review_id=$(EXPECTED_STATE="$expected_state" BOUNDARY_REVIEW_ID="$boundary" EXPECTED_HEAD_SHA="$head_sha" ACTING_LOGIN="$acting_login" \
|
||||||
python3 - "$reviews_response_file" <<'PY'
|
python3 - "$reviews_merged_file" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -327,22 +412,24 @@ try:
|
|||||||
expected_state = os.environ["EXPECTED_STATE"]
|
expected_state = os.environ["EXPECTED_STATE"]
|
||||||
boundary = int(os.environ["BOUNDARY_REVIEW_ID"])
|
boundary = int(os.environ["BOUNDARY_REVIEW_ID"])
|
||||||
expected_head = os.environ["EXPECTED_HEAD_SHA"]
|
expected_head = os.environ["EXPECTED_HEAD_SHA"]
|
||||||
# Require all of: created-after-boundary (this action's write), the
|
acting_login = os.environ["ACTING_LOGIN"]
|
||||||
# expected review state, and pinned to the PR's current head commit.
|
# Attribution to THIS action: created-after-boundary AND submitted by the
|
||||||
# The monotonic id boundary is what proves "submitted by this action"
|
# acting reviewer identity AND expected state AND pinned to the PR's
|
||||||
# rather than matching some pre-existing historical review.
|
# current head commit. The author check excludes a concurrent
|
||||||
|
# DIFFERENT-identity review that id+state+head alone would admit.
|
||||||
matches = [
|
matches = [
|
||||||
r for r in reviews
|
r for r in reviews
|
||||||
if isinstance(r, dict)
|
if isinstance(r, dict)
|
||||||
and isinstance(r.get("id"), int)
|
and isinstance(r.get("id"), int)
|
||||||
and r.get("id") > boundary
|
and r.get("id") > boundary
|
||||||
|
and (r.get("user") or {}).get("login") == acting_login
|
||||||
and r.get("state") == expected_state
|
and r.get("state") == expected_state
|
||||||
and r.get("commit_id") == expected_head
|
and r.get("commit_id") == expected_head
|
||||||
]
|
]
|
||||||
if not matches:
|
if not matches:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"no {expected_state} review created by this action found "
|
f"no {expected_state} review attributable to this action found "
|
||||||
"(id > boundary, expected state, current head); "
|
"(id > boundary, acting identity, expected state, current head); "
|
||||||
"tea may have silently failed (#865 defect class)"
|
"tea may have silently failed (#865 defect class)"
|
||||||
)
|
)
|
||||||
review_id = max(r["id"] for r in matches)
|
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
|
# strictly-newer review created by THIS action (never trust tea's
|
||||||
# exit code alone — #865 defect class applies to the review state).
|
# exit code alone — #865 defect class applies to the review state).
|
||||||
gitea_resolve_api || exit 1
|
gitea_resolve_api || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1
|
review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1
|
||||||
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
|
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
|
||||||
# route any review body via the durable comment API instead (#835).
|
# route any review body via the durable comment API instead (#835).
|
||||||
@@ -406,7 +494,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
|
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
|
||||||
fi
|
fi
|
||||||
tea "${TEA_ARGS[@]}"
|
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
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -427,6 +515,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
# Record the pre-write review-id boundary BEFORE the write (see the
|
# Record the pre-write review-id boundary BEFORE the write (see the
|
||||||
# approve path above for the rationale).
|
# approve path above for the rationale).
|
||||||
gitea_resolve_api || exit 1
|
gitea_resolve_api || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1
|
review_boundary=$(gitea_max_review_id "$PR_NUMBER") || exit 1
|
||||||
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
|
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
|
||||||
# route the review body via the durable comment API instead (#835).
|
# route the review body via the durable comment API instead (#835).
|
||||||
@@ -438,7 +527,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
|
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
|
||||||
fi
|
fi
|
||||||
tea "${TEA_ARGS[@]}"
|
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
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression harness for issue-comment.sh top-level `tea comment` invocation and
|
# 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
|
# 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
|
# 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
|
# read-back that matches ANY historical comment by body would falsely report
|
||||||
# success whenever an identically-bodied comment already exists from a prior
|
# 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`);
|
# 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
|
# 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
|
# strictly-newer comment on read-back, so a pre-existing identical body
|
||||||
# does NOT satisfy verification (fails closed);
|
# does NOT satisfy verification (fails closed);
|
||||||
# 3. reports success only when a genuinely new comment (id > boundary) with
|
# 3. attributes the matched comment to the acting identity (GET /user login),
|
||||||
# the exact body appears.
|
# 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
|
# 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
|
# "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
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -42,7 +47,10 @@ git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/sta
|
|||||||
|
|
||||||
ISSUE_NUMBER=7
|
ISSUE_NUMBER=7
|
||||||
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
||||||
|
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
||||||
BODY='durable "note" -- marker'
|
BODY='durable "note" -- marker'
|
||||||
|
ACTING_LOGIN="primary-reviewer"
|
||||||
|
FOREIGN_LOGIN="other-writer"
|
||||||
|
|
||||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -91,8 +99,9 @@ exit 92
|
|||||||
SH
|
SH
|
||||||
chmod +x "$BIN_DIR/tea"
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
# curl stub: serves GET .../issues/7/comments. First call = pre-write boundary,
|
# curl stub: serves GET /user (acting identity) and GET .../issues/7/comments
|
||||||
# second call = post-write read-back. The boundary always contains a
|
# (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,
|
# 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.
|
# which is exactly the condition a body-only match would trip over.
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
@@ -114,6 +123,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
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"
|
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
|
||||||
|
|
||||||
write_response() {
|
write_response() {
|
||||||
@@ -123,41 +136,58 @@ write_response() {
|
|||||||
printf '%s' "$status"
|
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"
|
calls_file="$ISSUE_COMMENT_CALLS"
|
||||||
if [[ -f "$calls_file" ]]; then
|
if [[ -f "$calls_file" ]]; then
|
||||||
# post-write read-back
|
# post-write read-back
|
||||||
if [[ "$ISSUE_COMMENT_TEST_MODE" == "fresh-success" ]]; then
|
response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" \
|
||||||
response=$(ISSUE_COMMENT_BODY="$ISSUE_COMMENT_EXPECTED_BODY" python3 - <<'PY'
|
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 json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
body = os.environ["ISSUE_COMMENT_BODY"]
|
body = os.environ["ISSUE_COMMENT_BODY"]
|
||||||
print(json.dumps([
|
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
|
||||||
{"id": 50, "body": body},
|
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
|
||||||
{"id": 60, "body": body},
|
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
|
||||||
]))
|
|
||||||
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"]
|
if mode == "fresh-success":
|
||||||
print(json.dumps([{"id": 50, "body": body}]))
|
# 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
|
PY
|
||||||
)
|
)
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
: > "$calls_file"
|
: > "$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 json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
body = os.environ["ISSUE_COMMENT_BODY"]
|
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
|
PY
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
@@ -184,7 +214,10 @@ run_comment() {
|
|||||||
ISSUE_COMMENT_CALLS="$CALLS_FILE" \
|
ISSUE_COMMENT_CALLS="$CALLS_FILE" \
|
||||||
ISSUE_COMMENT_TEST_MODE="$mode" \
|
ISSUE_COMMENT_TEST_MODE="$mode" \
|
||||||
ISSUE_COMMENT_EXPECTED_BODY="$BODY" \
|
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_BASE="$API_BASE" \
|
||||||
|
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY"
|
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY"
|
||||||
) > "$OUTPUT_FILE" 2>&1
|
) > "$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
|
echo "FAIL: wrapper used the broken 'tea issue comment' subcommand" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
run_comment fresh-success
|
||||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 60)' "$OUTPUT_FILE"
|
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 60)' "$OUTPUT_FILE"
|
||||||
grep -q "^comment 7 " "$TEA_LOG"
|
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"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
ACTING_LOGIN="review-bot"
|
||||||
|
FOREIGN_LOGIN="other-writer"
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
@@ -67,7 +70,7 @@ if [[ "$*" == *" -comment "* || "$*" == *" --comment "* || "$*" == *" -comment"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||||
approve)
|
approve|paginated-approve|foreign-review)
|
||||||
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
@@ -127,6 +130,12 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
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"
|
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
|
||||||
|
|
||||||
write_response() {
|
write_response() {
|
||||||
@@ -144,32 +153,83 @@ case "${PR_REVIEW_TEST_MODE:-}" in
|
|||||||
write-http-failure)
|
write-http-failure)
|
||||||
write_response 500 '{"message":"simulated rejection"}'
|
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)
|
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" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
|
if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then
|
||||||
# First GET = pre-write review-id BOUNDARY; second GET = post-write
|
# Acting reviewer identity used for invocation attribution.
|
||||||
# read-back that must surface a strictly-newer review created by
|
write_response 200 "$(PR_REVIEW_LOGIN="$PR_REVIEW_ACTING_LOGIN" python3 - <<'PY'
|
||||||
# THIS action (id 200 > boundary 100), with the expected state and
|
import json
|
||||||
# pinned to the PR's current head commit.
|
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}"
|
calls_file="${PR_REVIEW_REVIEW_CALLS:-/dev/null}"
|
||||||
if [[ -f "$calls_file" ]]; then
|
if [[ -f "$calls_file" ]]; then
|
||||||
state="APPROVED"
|
phase="post"
|
||||||
[[ "$PR_REVIEW_TEST_MODE" == "request-changes" ]] && state="REQUEST_CHANGES"
|
else
|
||||||
response=$(PR_REVIEW_STATE="$state" python3 - <<'PY'
|
: > "$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 json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
print(json.dumps([
|
phase = os.environ["PR_REVIEW_PHASE"]
|
||||||
{"id": 100, "state": "COMMENT", "commit_id": "oldsha0000"},
|
page = int(os.environ["PR_REVIEW_PAGE"])
|
||||||
{"id": 200, "state": os.environ["PR_REVIEW_STATE"], "commit_id": "HEADSHA_FEEDFACE"},
|
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
|
PY
|
||||||
)
|
)
|
||||||
else
|
|
||||||
: > "$calls_file"
|
|
||||||
response='[{"id":100,"state":"COMMENT","commit_id":"oldsha0000"}]'
|
|
||||||
fi
|
|
||||||
write_response 200 "$response"
|
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"}}'
|
write_response 200 '{"head":{"sha":"HEADSHA_FEEDFACE"}}'
|
||||||
elif [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
elif [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||||
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||||
@@ -222,6 +282,7 @@ run_review() {
|
|||||||
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
||||||
local expected_repo="${6:-mosaicstack/stack}"
|
local expected_repo="${6:-mosaicstack/stack}"
|
||||||
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
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"
|
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||||
write_credentials "$configured_url"
|
write_credentials "$configured_url"
|
||||||
: > "$TEA_LOG"
|
: > "$TEA_LOG"
|
||||||
@@ -238,6 +299,9 @@ run_review() {
|
|||||||
PR_REVIEW_TEST_MODE="$mode" \
|
PR_REVIEW_TEST_MODE="$mode" \
|
||||||
PR_REVIEW_EXPECTED_BODY="$comment" \
|
PR_REVIEW_EXPECTED_BODY="$comment" \
|
||||||
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
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"}
|
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||||
) > "$OUTPUT_FILE" 2>&1
|
) > "$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 '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||||
grep -q 'Approved and verified Gitea PR #123 (review ID 200)' "$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
|
# #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.
|
# post-write read-back GET on the (paginated) reviews endpoint, plus a PR head
|
||||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
|
# 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/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
|
if grep -q 'comment' "$TEA_LOG"; then
|
||||||
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
|
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# #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
|
# review body supplied alongside approve must be routed through the durable
|
||||||
# comment REST API instead of being passed to `tea` directly.
|
# 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
|
run_review request-changes request-changes changes-required
|
||||||
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
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 '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 '^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 '^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"
|
grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user