Compare commits
6 Commits
main
...
fix/812-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571b65d336 | ||
|
|
1b1902010a | ||
|
|
0115d92fda | ||
|
|
79988a4e13 | ||
|
|
ea7f8c5758 | ||
|
|
770e3f57ed |
58
docs/scratchpads/812-pr-review-comment.md
Normal file
58
docs/scratchpads/812-pr-review-comment.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Issue #812 — durable Gitea PR review comments
|
||||||
|
|
||||||
|
- **Lane:** ms-812
|
||||||
|
- **Branch:** `fix/812-pr-review-comment`
|
||||||
|
- **Issue:** mosaicstack/stack#812
|
||||||
|
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add and commit a failing shell regression harness before production changes.
|
||||||
|
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
|
||||||
|
3. Implement the minimal supported write plus ID-based provider read-back.
|
||||||
|
4. Document that wrapper write output is not durable provenance until read-back succeeds.
|
||||||
|
5. Run focused regression tests, touched-package tests, and repository quality gates.
|
||||||
|
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
|
||||||
|
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
|
||||||
|
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
|
||||||
|
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
|
||||||
|
- [x] Focused, package, and repository gates green
|
||||||
|
- [ ] Coordinator-owned independent review pending after push
|
||||||
|
- [x] No PR opened; no self-review or self-merge
|
||||||
|
|
||||||
|
## Tests run
|
||||||
|
|
||||||
|
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
|
||||||
|
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
|
||||||
|
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
|
||||||
|
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
|
||||||
|
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
|
||||||
|
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
|
||||||
|
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
|
||||||
|
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
|
||||||
|
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
|
||||||
|
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
|
||||||
|
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
|
||||||
|
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
|
||||||
|
- Existing approve/request-changes behavior remains covered.
|
||||||
|
- Independent exact-head re-review remains coordinator-owned.
|
||||||
|
|
||||||
|
## Final verification evidence
|
||||||
|
|
||||||
|
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
|
||||||
|
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
|
||||||
|
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
|
||||||
|
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
|
||||||
|
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.
|
||||||
9
packages/mosaic/framework/tools/git/README.md
Normal file
9
packages/mosaic/framework/tools/git/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Git provider wrappers
|
||||||
|
|
||||||
|
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
`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.
|
||||||
@@ -81,7 +81,26 @@ get_repo_slug() {
|
|||||||
gitea_url_matches_host() {
|
gitea_url_matches_host() {
|
||||||
local url="${1:-}" host="${2:-}"
|
local url="${1:-}" host="${2:-}"
|
||||||
[[ -n "$url" && -n "$host" ]] || return 1
|
[[ -n "$url" && -n "$host" ]] || return 1
|
||||||
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
|
python3 - "$url" "$host" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
url, remote_host = sys.argv[1:]
|
||||||
|
configured = urlparse(url)
|
||||||
|
remote = urlparse(f"//{remote_host}")
|
||||||
|
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
configured_port = configured.port
|
||||||
|
remote_port = remote.port
|
||||||
|
if remote_port is None:
|
||||||
|
default_port = 80 if configured.scheme == "http" else 443
|
||||||
|
if configured_port not in {None, default_port}:
|
||||||
|
raise SystemExit(1)
|
||||||
|
elif configured_port != remote_port:
|
||||||
|
raise SystemExit(1)
|
||||||
|
raise SystemExit(0)
|
||||||
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
@@ -347,6 +366,47 @@ get_gitea_api_host_for_repo_override() {
|
|||||||
get_host_from_url "${GITEA_URL:-}"
|
get_host_from_url "${GITEA_URL:-}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
|
||||||
|
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
|
||||||
|
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
|
||||||
|
# forms retain their existing owner/repo behavior.
|
||||||
|
get_gitea_repo_slug_for_url() {
|
||||||
|
local configured_url="$1" remote_url
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||||
|
|
||||||
|
if [[ "$remote_url" =~ ^https?:// ]]; then
|
||||||
|
python3 - "$remote_url" "$configured_url" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
remote = urlparse(sys.argv[1])
|
||||||
|
base = urlparse(sys.argv[2])
|
||||||
|
remote_path = remote.path.strip("/")
|
||||||
|
if remote_path.endswith(".git"):
|
||||||
|
remote_path = remote_path[:-4]
|
||||||
|
base_path = base.path.strip("/")
|
||||||
|
remote_parts = [part for part in remote_path.split("/") if part]
|
||||||
|
base_parts = [part for part in base_path.split("/") if part]
|
||||||
|
|
||||||
|
if base_parts and remote_parts[:len(base_parts)] == base_parts:
|
||||||
|
repo_parts = remote_parts[len(base_parts):]
|
||||||
|
elif len(remote_parts) == 2:
|
||||||
|
# Preserve a root-shaped clone URL when provider API configuration carries
|
||||||
|
# a reverse-proxy prefix separately.
|
||||||
|
repo_parts = remote_parts
|
||||||
|
else:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if len(repo_parts) != 2:
|
||||||
|
raise SystemExit(1)
|
||||||
|
print("/".join(repo_parts))
|
||||||
|
PY
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
get_repo_slug
|
||||||
|
}
|
||||||
|
|
||||||
get_gitea_repo_args() {
|
get_gitea_repo_args() {
|
||||||
local repo host login
|
local repo host login
|
||||||
repo=$(get_repo_slug) || return 1
|
repo=$(get_repo_slug) || return 1
|
||||||
@@ -370,6 +430,11 @@ get_remote_host() {
|
|||||||
echo "${host##*@}"
|
echo "${host##*@}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||||
|
local host="${BASH_REMATCH[1]}"
|
||||||
|
echo "${host##*@}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||||
echo "${BASH_REMATCH[1]}"
|
echo "${BASH_REMATCH[1]}"
|
||||||
return 0
|
return 0
|
||||||
@@ -377,6 +442,51 @@ get_remote_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve the configured Gitea base URL for a host from the same credential
|
||||||
|
# source used by get_gitea_token. The scheme and any deployment path prefix are
|
||||||
|
# provider configuration and must not be reconstructed from the git remote.
|
||||||
|
get_gitea_url_for_host() {
|
||||||
|
local host="$1" script_dir cred_loader url
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cred_loader="$script_dir/../_lib/credentials.sh"
|
||||||
|
|
||||||
|
if [[ -f "$cred_loader" ]]; then
|
||||||
|
url=$(
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$cred_loader"
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
case "$host" in
|
||||||
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||||
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||||
|
*)
|
||||||
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
printf '%s' "${GITEA_URL%/}"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
if [[ -n "$url" ]]; then
|
||||||
|
printf '%s\n' "$url"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
printf '%s\n' "${GITEA_URL%/}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
@@ -403,7 +513,7 @@ get_gitea_token() {
|
|||||||
for svc in gitea-mosaicstack gitea-usc; do
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
unset GITEA_TOKEN GITEA_URL
|
unset GITEA_TOKEN GITEA_URL
|
||||||
load_credentials "$svc" 2>/dev/null || continue
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
matched=true
|
matched=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -423,7 +533,7 @@ get_gitea_token() {
|
|||||||
|
|
||||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||||
echo "$GITEA_TOKEN"
|
echo "$GITEA_TOKEN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -85,7 +86,10 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
repo=$(get_repo_slug)
|
||||||
|
host=$(get_remote_host)
|
||||||
|
login=$(get_gitea_login_for_host "$host")
|
||||||
|
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login" ${COMMENT:+--comment "$COMMENT"}
|
||||||
echo "Approved Gitea PR #$PR_NUMBER"
|
echo "Approved Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
@@ -93,7 +97,10 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
repo=$(get_repo_slug)
|
||||||
|
host=$(get_remote_host)
|
||||||
|
login=$(get_gitea_login_for_host "$host")
|
||||||
|
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login" --comment "$COMMENT"
|
||||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
@@ -101,8 +108,103 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
|
||||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
host=$(get_remote_host)
|
||||||
|
token=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for comment persistence" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||||
|
echo "Error: Configured Gitea URL not found for comment persistence" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||||
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
api_base="${configured_url%/}/api/v1/repos/$repo"
|
||||||
|
payload=$(COMMENT_BODY="$COMMENT" python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||||
|
')
|
||||||
|
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||||
|
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
|
||||||
|
trap 'rm -f "$write_response_file" "$readback_response_file"' EXIT
|
||||||
|
|
||||||
|
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$api_base/issues/$PR_NUMBER/comments"); then
|
||||||
|
echo "Error: Gitea comment write transport failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$write_status" != "201" ]]; then
|
||||||
|
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
comment_id=$(python3 - "$write_response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
comment = json.load(response)
|
||||||
|
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
||||||
|
if not isinstance(comment_id, int) or comment_id <= 0:
|
||||||
|
raise ValueError("missing positive comment id")
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||||
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(comment_id)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
"$api_base/issues/comments/$comment_id"); then
|
||||||
|
echo "Error: Gitea comment read-back transport failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$COMMENT" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$PR_NUMBER" \
|
||||||
|
python3 - "$readback_response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
comment = json.load(response)
|
||||||
|
if not isinstance(comment, dict):
|
||||||
|
raise ValueError("response is not a comment object")
|
||||||
|
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||||
|
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||||
|
expected_repo = os.environ["EXPECTED_REPO"]
|
||||||
|
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
|
||||||
|
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||||
|
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
|
||||||
|
if comment.get("id") != expected_id:
|
||||||
|
raise ValueError("comment id mismatch")
|
||||||
|
if comment.get("body") != expected_body:
|
||||||
|
raise ValueError("comment body mismatch")
|
||||||
|
if not issue_path.endswith(expected_suffix):
|
||||||
|
raise ValueError("repository or PR mismatch")
|
||||||
|
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||||
|
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Unknown action: $ACTION"
|
echo "Error: Unknown action: $ACTION"
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for durable Gitea PR review comments (#812).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
|
||||||
|
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
write_credentials() {
|
||||||
|
local configured_url="$1"
|
||||||
|
CONFIGURED_GITEA_URL="$configured_url" 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
|
||||||
|
}
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
|
||||||
|
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||||
|
approve)
|
||||||
|
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
||||||
|
;;
|
||||||
|
request-changes)
|
||||||
|
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required" ]] || exit 91
|
||||||
|
;;
|
||||||
|
legacy-fallback|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|write-transport-failure|write-http-failure|readback-failure)
|
||||||
|
if [[ "$*" == pr\ comment* ]]; then
|
||||||
|
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
||||||
|
printf '%s\n' 'INDEX TITLE STATE'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Unexpected tea command: $*" >&2
|
||||||
|
exit 92
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 95
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
output_file=""
|
||||||
|
method="GET"
|
||||||
|
payload=""
|
||||||
|
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)
|
||||||
|
payload="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-s|-S|-sS)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
http://*|https://*)
|
||||||
|
url="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
|
||||||
|
|
||||||
|
write_response() {
|
||||||
|
local status="$1" body="$2"
|
||||||
|
[[ -n "$output_file" ]] || exit 96
|
||||||
|
printf '%s' "$body" > "$output_file"
|
||||||
|
printf '%s' "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||||
|
legacy-fallback|write-transport-failure)
|
||||||
|
echo "simulated transport failure" >&2
|
||||||
|
exit 7
|
||||||
|
;;
|
||||||
|
write-http-failure)
|
||||||
|
write_response 500 '{"message":"simulated rejection"}'
|
||||||
|
;;
|
||||||
|
comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|readback-failure)
|
||||||
|
if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||||
|
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
assert json.loads(os.environ["PR_REVIEW_PAYLOAD"]) == {"body": os.environ["PR_REVIEW_EXPECTED_BODY"]}
|
||||||
|
PY
|
||||||
|
response=$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"id": 456, "body": os.environ["PR_REVIEW_EXPECTED_BODY"]}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
write_response 201 "$response"
|
||||||
|
elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/comments/456" ]]; then
|
||||||
|
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
|
||||||
|
body="different-body"
|
||||||
|
else
|
||||||
|
body="$PR_REVIEW_EXPECTED_BODY"
|
||||||
|
fi
|
||||||
|
response=$(PR_REVIEW_BODY="$body" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"id": 456,
|
||||||
|
"body": os.environ["PR_REVIEW_BODY"],
|
||||||
|
"issue_url": os.environ["PR_REVIEW_EXPECTED_API_BASE"] + "/issues/123",
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
write_response 200 "$response"
|
||||||
|
else
|
||||||
|
echo "Unexpected curl request: $method $url" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 98
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
run_review() {
|
||||||
|
local mode="$1" action="$2" comment="${3:-}"
|
||||||
|
local configured_url="${4:-https://git.mosaicstack.dev}"
|
||||||
|
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
||||||
|
local expected_repo="${6:-mosaicstack/stack}"
|
||||||
|
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
||||||
|
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||||
|
write_credentials "$configured_url"
|
||||||
|
: > "$TEA_LOG"
|
||||||
|
: > "$CURL_LOG"
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
(
|
||||||
|
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_TEST_MODE="$mode" \
|
||||||
|
PR_REVIEW_EXPECTED_BODY="$comment" \
|
||||||
|
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
||||||
|
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||||
|
) > "$OUTPUT_FILE" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_review approve approve
|
||||||
|
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||||
|
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
run_review request-changes request-changes changes-required
|
||||||
|
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required$' "$TEA_LOG"
|
||||||
|
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
if run_review legacy-fallback comment durable-body; then
|
||||||
|
echo "The old nonexistent tea pr comment fallback returned success" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '^pr comment ' "$TEA_LOG"; then
|
||||||
|
echo "Wrapper invoked unsupported tea pr comment" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
|
||||||
|
echo "Wrapper reported success without durable persistence" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
complex_body=$'durable "body"\n-- marker'
|
||||||
|
run_review comment-success comment "$complex_body"
|
||||||
|
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 comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
|
if [[ -s "$TEA_LOG" ]]; then
|
||||||
|
echo "REST comment path unexpectedly invoked tea" >&2
|
||||||
|
cat "$TEA_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_review http-success comment durable-body http://git.mosaicstack.dev
|
||||||
|
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
|
||||||
|
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||||
|
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
|
||||||
|
echo "Configured Gitea path prefix leaked into the repository slug" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
|
||||||
|
if run_review write-transport-failure comment durable-body; then
|
||||||
|
echo "Expected provider transport failure to return nonzero" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if run_review write-http-failure comment durable-body; then
|
||||||
|
echo "Expected non-201 provider write to return nonzero" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if run_review readback-failure comment durable-body; then
|
||||||
|
echo "Expected mismatched provider read-back to return nonzero" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "Read-back mismatch reported durable success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-review.sh durable Gitea comment regression passed"
|
||||||
Reference in New Issue
Block a user