Compare commits

..

3 Commits

Author SHA1 Message Date
Hermes Agent
10fdd49e32 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>
2026-07-21 18:21:02 -05:00
Hermes Agent
a27f1fa7df fix(tools): use top-level tea comment invocation and formalize --login passthrough (#865)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
issue-comment.sh called the non-existent `tea issue comment` subcommand
form; tea 0.11.1 silently no-ops and exits 0 instead of erroring, producing
a false-success write. Switch to the top-level `tea comment <index> <body>`
form and add fail-closed REST read-back verification so the wrapper no
longer trusts tea's exit code alone.

pr-review.sh's comment path was already fixed for this bug by #812/#835
(routes through a read-back-verified REST comment API instead of any
tea comment subcommand); this change formalizes an explicit --login
override flag there too and documents the after-detection last-wins
--login ordering, consistent with issue-comment.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:50:43 -05:00
4e5af23214 Merge pull request 'skills: add glpi-* family (solve, followup, sweep, list, create)' (#863) from feat/glpi-skills into main
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-21 01:09:50 +00:00
5 changed files with 654 additions and 16 deletions

View File

@@ -4,6 +4,19 @@ 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, 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)
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`**. The correct invocation is the **top-level** `tea comment <index> <body> [--repo ...] [--login ...]`. The `tea pr comment` / `tea issue comment` forms don't error — tea silently falls through to a no-op and still exits 0, producing a false-success write (#865). Always use the top-level form.
- `tea pr approve` and `tea pr reject` take an optional review comment/reason as a **trailing positional argument**, not a `--comment`/`-comment` flag (that flag does not exist on those subcommands). `pr-review.sh` avoids this positional form entirely for the approve/reject actions and instead posts any review comment through the same durable, read-back-verified comment API used for the `comment` action (see #835/#812) — the trailing-positional form remains available to callers who invoke `tea` directly, but is not used by these wrappers.
### `--login` passthrough
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea `tea` login for that single invocation. The override is appended to the `tea` command line **after** the detected default (`get_gitea_repo_args()` / `get_gitea_login[_for_host]()`), because tea honors only the **last** `--login` flag on its command line — an override placed before the default would be silently clobbered by it. Callers who need a different login than the host default should pass `--login <reviewer-login>` rather than relying on ordering tricks or re-invoking `tea login` globally.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.

View File

@@ -1,6 +1,23 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment>
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the correct invocation is the TOP-LEVEL `tea comment <index> <body>` form.
# Calling the non-existent `tea issue comment ...` form does not error — tea
# silently falls through to a no-op and still exits 0, so a caller trusting
# the exit code alone believes a comment was posted when it was not (#865).
# Because that failure mode is silent, this script never trusts tea's exit
# code alone: after posting, it independently re-fetches the issue's comments
# via the Gitea REST API (curl — urllib is blocked by Cloudflare on this
# host) and fails closed if the posted body cannot be found.
#
# --login override: the default `--login` is resolved from the local `tea`
# login list for this repo's host (get_gitea_login). Pass --login <name> to
# override that default for this invocation only. The override is appended
# to the tea command line AFTER the detected default, because tea honors
# only the LAST `--login` flag on the command line — a flag placed before
# the default would be silently clobbered by it.
set -e
@@ -10,6 +27,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -21,12 +39,17 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
exit 0
;;
@@ -49,6 +72,127 @@ fi
detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote.
# Populates GITEA_API_BASE and GITEA_API_TOKEN. Returns non-zero (with a
# clear stderr message) if any part of the resolution fails.
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 comment read-back verification" >&2
return 1
}
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment 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 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 ! status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
-H "Authorization: token $GITEA_API_TOKEN" \
"$GITEA_API_BASE/issues/$issue_number/comments"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $status" >&2
return 1
fi
EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" \
python3 - "$readback_response_file" <<'PY'
import json
import os
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")
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
boundary = int(os.environ["BOUNDARY_COMMENT_ID"])
# Require both: created-after-boundary (fresh write) AND exact body match.
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 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)
print(comment_id)
PY
}
if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
@@ -61,8 +205,26 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
exit 1
}
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
# 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
# would be silently clobbered by it.
if [[ -n "$LOGIN_OVERRIDE" ]]; then
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
fi
tea "${TEA_ARGS[@]}"
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)"
else
echo "Error: Unknown platform"
exit 1

View File

@@ -1,6 +1,17 @@
#!/bin/bash
# pr-review.sh - Review a pull request on GitHub or Gitea
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]
#
# --login override: approve/request-changes on Gitea invoke `tea pr
# approve`/`tea pr reject` with a `--login` resolved from the local tea
# login list for this repo's host (get_gitea_login_for_host). Pass
# --login <name> to override that default for this invocation only. The
# override is appended to the tea command line AFTER the detected default
# (get_gitea_repo_args()-equivalent resolution happens first), because tea
# honors only the LAST `--login` flag on its command line — a flag placed
# before the default would be silently clobbered by it. The `comment`
# action does not shell out to `tea` at all (see gitea_post_verified_comment
# below), so --login has no effect on it.
set -e
@@ -12,6 +23,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
ACTION=""
COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -27,13 +39,18 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -h, --help Show this help"
exit 0
;;
@@ -175,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)
@@ -208,10 +390,27 @@ 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 pr approve "$PR_NUMBER" --repo "$repo" --login "$login"
echo "Approved Gitea PR #$PR_NUMBER"
TEA_ARGS=(pr approve "$PR_NUMBER" --repo "$repo" --login "$login")
# --login override goes LAST: tea honors only the final --login on
# its command line, so an override placed before the detected
# default above would be silently clobbered by it.
if [[ -n "$LOGIN_OVERRIDE" ]]; then
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
fi
tea "${TEA_ARGS[@]}"
review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "APPROVED" "$review_boundary") || {
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)"
@@ -225,10 +424,25 @@ 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 pr reject "$PR_NUMBER" --repo "$repo" --login "$login"
echo "Requested changes on Gitea PR #$PR_NUMBER"
TEA_ARGS=(pr reject "$PR_NUMBER" --repo "$repo" --login "$login")
# --login override goes LAST: tea honors only the final --login on
# its command line, so an override placed before the detected
# default above would be silently clobbered by it.
if [[ -n "$LOGIN_OVERRIDE" ]]; then
TEA_ARGS+=(--login "$LOGIN_OVERRIDE")
fi
tea "${TEA_ARGS[@]}"
review_id=$(gitea_verify_review_submitted "$PR_NUMBER" "REQUEST_CHANGES" "$review_boundary") || {
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"