Compare commits
7 Commits
fix/812-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d78d8632 | |||
| 344d86a635 | |||
| acd7d380f6 | |||
| 3b70c66c07 | |||
| 11d2818453 | |||
| aa999daf1b | |||
| 77c9a82614 |
@@ -64,7 +64,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||||
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||||
| FCM-M5-001 | in-progress | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Sole owner: this FCM-M5-001 delivery on the recorded branch; must close every checklist item or record an approved deferral |
|
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|||||||
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,32 @@ 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)
|
||||||
|
|
||||||
|
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
||||||
|
# default-port form (":80" for http, ":443" for https) name the same
|
||||||
|
# provider endpoint. Apply that equivalence symmetrically -- whichever side
|
||||||
|
# omits the port is treated as carrying the scheme's default port -- so
|
||||||
|
# "configured implicit vs. remote explicit" and "configured explicit vs.
|
||||||
|
# remote implicit" both match. (The remote side here is always an HTTP(S)
|
||||||
|
# authority; an SSH remote's transport port is stripped by get_remote_host
|
||||||
|
# before reaching this comparison, since it identifies an unrelated
|
||||||
|
# service on the same host, not the HTTP(S) provider port.)
|
||||||
|
default_port = 80 if configured.scheme == "http" else 443
|
||||||
|
normalized_configured = configured.port if configured.port is not None else default_port
|
||||||
|
normalized_remote = remote.port if remote.port is not None else default_port
|
||||||
|
if normalized_configured != normalized_remote:
|
||||||
|
raise SystemExit(1)
|
||||||
|
raise SystemExit(0)
|
||||||
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
@@ -347,6 +372,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 +436,15 @@ get_remote_host() {
|
|||||||
echo "${host##*@}"
|
echo "${host##*@}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||||
|
local host="${BASH_REMATCH[1]}"
|
||||||
|
host="${host##*@}"
|
||||||
|
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
|
||||||
|
# SSH daemon port, not the HTTP(S) provider API port, and must not
|
||||||
|
# feed gitea_url_matches_host's port comparison (#850).
|
||||||
|
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 +452,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 +523,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 +543,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
|
||||||
@@ -55,6 +56,125 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
|
# Post a review comment body to a Gitea PR via the supported comments REST API
|
||||||
|
# and verify it durably via provider read-back (see docs on durable review
|
||||||
|
# provenance in README.md). Used by the `comment` action and, since `tea`
|
||||||
|
# v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`,
|
||||||
|
# also by the `approve` and `request-changes` actions to carry an optional
|
||||||
|
# review body that `tea` itself cannot attach.
|
||||||
|
#
|
||||||
|
# Args: $1 = PR number, $2 = comment body
|
||||||
|
# On success: prints only the created comment ID to stdout, returns 0.
|
||||||
|
# On failure: prints an error to stderr, returns 1.
|
||||||
|
gitea_post_verified_comment() {
|
||||||
|
local pr_number="$1" comment_body="$2"
|
||||||
|
local host token configured_url repo api_base payload
|
||||||
|
local write_response_file readback_response_file comment_id
|
||||||
|
|
||||||
|
host=$(get_remote_host)
|
||||||
|
token=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for comment persistence" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||||
|
echo "Error: Configured Gitea URL not found for comment persistence" >&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
|
||||||
|
}
|
||||||
|
api_base="${configured_url%/}/api/v1/repos/$repo"
|
||||||
|
payload=$(COMMENT_BODY="$comment_body" 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"' RETURN
|
||||||
|
|
||||||
|
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
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$write_status" != "201" ]]; then
|
||||||
|
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||||
|
return 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
|
||||||
|
) || return 1
|
||||||
|
|
||||||
|
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
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" 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
|
||||||
|
then
|
||||||
|
true
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$comment_id"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
@@ -85,24 +205,41 @@ 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 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"
|
echo "Approved Gitea PR #$PR_NUMBER"
|
||||||
|
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)"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; 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 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"
|
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||||
|
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)"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; 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"
|
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||||
|
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,328 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for durable Gitea PR review comments (#812) and for the
|
||||||
|
# approve/reject `--comment` flag removal (#835). The `tea` stub below rejects
|
||||||
|
# any `-comment`/`--comment` flag on `pr approve`/`pr reject` exactly like real
|
||||||
|
# `tea` v0.11.1 does ("flag provided but not defined: -comment"), so this
|
||||||
|
# harness fails RED against the pre-#835 wrapper (which passed that flag) and
|
||||||
|
# only passes once the wrapper routes the review body through the durable
|
||||||
|
# comment REST API instead.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# tea v0.11.1 defines no --comment/-comment flag on `pr approve` or `pr
|
||||||
|
# reject`; it fails closed with this exact message and a nonzero exit. Any
|
||||||
|
# regression that reintroduces the flag on those subcommands must hit this
|
||||||
|
# branch and fail RED (#835).
|
||||||
|
if [[ "$*" == *" -comment "* || "$*" == *" --comment "* || "$*" == *" -comment" || "$*" == *" --comment" ]]; then
|
||||||
|
echo "flag provided but not defined: -comment" >&2
|
||||||
|
exit 1
|
||||||
|
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" ]] || exit 91
|
||||||
|
;;
|
||||||
|
legacy-fallback|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-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"}'
|
||||||
|
;;
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
if grep -q 'comment' "$TEA_LOG"; then
|
||||||
|
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# #835: tea v0.11.1 defines no --comment/-comment flag on `pr approve`. A
|
||||||
|
# review body supplied alongside approve must be routed through the durable
|
||||||
|
# comment REST API instead of being passed to `tea` directly.
|
||||||
|
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 '^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"
|
||||||
|
|
||||||
|
# #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 '^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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
# #850 (follow-up to #812): an SSH remote's transport port (e.g. `ssh://
|
||||||
|
# git@host:2222/...`) must NOT be compared against the configured HTTP(S) API
|
||||||
|
# URL's port -- they identify unrelated properties (SSH daemon port vs. HTTP(S)
|
||||||
|
# provider port) of the same Gitea host. Before the fix, host-match required
|
||||||
|
# the configured URL to carry the identical port, so this failed closed even
|
||||||
|
# though both remote and configured URL name the same host.
|
||||||
|
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
|
||||||
|
# #850 (follow-up to #812): an explicit default HTTP(S) port on the remote
|
||||||
|
# (`https://host:443/...`) must be treated as equal to an implicit
|
||||||
|
# (portless) configured URL on BOTH sides -- the pre-fix comparison only
|
||||||
|
# normalized the default port when the REMOTE side was portless, so the
|
||||||
|
# inverse (explicit remote, implicit configured) form failed closed.
|
||||||
|
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/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"
|
||||||
@@ -50,6 +50,21 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
|
||||||
|
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
|
||||||
|
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
|
||||||
|
# freshly created git worktree (pnpm workspaces do NOT share node_modules
|
||||||
|
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
|
||||||
|
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
|
||||||
|
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
|
||||||
|
# Fail legibly here instead, before that raw error has a chance to surface.
|
||||||
|
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
|
||||||
|
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
|
||||||
|
echo "deps not installed — run pnpm install" >&2
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Call the main QA handler with extracted parameters
|
# Call the main QA handler with extracted parameters
|
||||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
||||||
|
|||||||
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
|
||||||
|
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
|
||||||
|
# share node_modules across worktrees). Before the fix, the gate-entry seam
|
||||||
|
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
|
||||||
|
# MultiEdit in runtime/claude/settings.json) silently let a raw
|
||||||
|
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
|
||||||
|
# indistinguishable from a real test/lint failure (false-red).
|
||||||
|
#
|
||||||
|
# Asserts:
|
||||||
|
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
|
||||||
|
# at all produces the raw "not found" for a gate binary — this is the
|
||||||
|
# defect the fix prevents from reaching the operator un-annotated.
|
||||||
|
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
|
||||||
|
# the legible sentinel "deps not installed — run pnpm install" instead
|
||||||
|
# of silently proceeding (exit 0) into a would-be raw not-found.
|
||||||
|
# 3. With node_modules/.bin present but empty, same legible-sentinel
|
||||||
|
# behavior (covers `git worktree add` immediately followed by an
|
||||||
|
# as-yet-incomplete/interrupted install).
|
||||||
|
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
|
||||||
|
# proceeds normally (exit 0) — the preflight does not false-positive.
|
||||||
|
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
|
||||||
|
|
||||||
|
TMP_DIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
fail_msg() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
fail=1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_hook() {
|
||||||
|
local file_path="$1"
|
||||||
|
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_fixture_repo() {
|
||||||
|
local dir="$1"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
git -C "$dir" init -q .
|
||||||
|
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
|
||||||
|
# node_modules/.bin is entirely absent (this is what the preflight now
|
||||||
|
# intercepts before any gate command runs).
|
||||||
|
RED_DIR="$TMP_DIR/red-fixture"
|
||||||
|
make_fixture_repo "$RED_DIR"
|
||||||
|
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
|
||||||
|
case "$RED_OUTPUT" in
|
||||||
|
*"not found"*) ;;
|
||||||
|
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
|
||||||
|
|
||||||
|
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
|
||||||
|
MISSING_DIR="$TMP_DIR/missing-bin"
|
||||||
|
make_fixture_repo "$MISSING_DIR"
|
||||||
|
echo "console.log(1)" > "$MISSING_DIR/x.ts"
|
||||||
|
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||||
|
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"deps not installed"*"pnpm install"*) ;;
|
||||||
|
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
|
||||||
|
EMPTY_DIR="$TMP_DIR/empty-bin"
|
||||||
|
make_fixture_repo "$EMPTY_DIR"
|
||||||
|
mkdir -p "$EMPTY_DIR/node_modules/.bin"
|
||||||
|
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
|
||||||
|
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||||
|
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"deps not installed"*"pnpm install"*) ;;
|
||||||
|
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
|
||||||
|
OK_DIR="$TMP_DIR/installed-bin"
|
||||||
|
make_fixture_repo "$OK_DIR"
|
||||||
|
mkdir -p "$OK_DIR/node_modules/.bin"
|
||||||
|
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
|
||||||
|
chmod +x "$OK_DIR/node_modules/.bin/tsc"
|
||||||
|
echo "console.log(1)" > "$OK_DIR/x.ts"
|
||||||
|
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||||
|
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
|
||||||
|
# skipped before the deps check, regardless of node_modules state).
|
||||||
|
NONJS_DIR="$TMP_DIR/nonjs"
|
||||||
|
make_fixture_repo "$NONJS_DIR"
|
||||||
|
echo "# doc" > "$NONJS_DIR/README.md"
|
||||||
|
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
|
||||||
|
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
|
||||||
|
case "$OUTPUT" in
|
||||||
|
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "deps-preflight regression passed (5/5 scenarios)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -31,17 +31,26 @@ PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
|||||||
|
|
||||||
|
|
||||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
deadline = time.monotonic() + 5.0
|
||||||
connection.settimeout(3.0)
|
while True:
|
||||||
connection.connect(str(socket_path))
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
connection.settimeout(3.0)
|
||||||
connection.shutdown(socket.SHUT_WR)
|
try:
|
||||||
response = bytearray()
|
connection.connect(str(socket_path))
|
||||||
while True:
|
except ConnectionRefusedError:
|
||||||
chunk = connection.recv(4096)
|
if time.monotonic() >= deadline:
|
||||||
if not chunk:
|
raise
|
||||||
break
|
time.sleep(0.02)
|
||||||
response.extend(chunk)
|
continue
|
||||||
|
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
response = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = connection.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response.extend(chunk)
|
||||||
|
break
|
||||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||||
reply = json.loads(response[:-1])
|
reply = json.loads(response[:-1])
|
||||||
|
|||||||
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
const sessionId = await register(socket);
|
||||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
|
||||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
|
||||||
|
|
||||||
|
// Establish the lease with a normal (non-racing) TTL first and prove it
|
||||||
|
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
||||||
|
// assertion, so it must not share a lease with a 1-second TTL: on a
|
||||||
|
// contended push-CI host, scheduling delay alone between promote() and
|
||||||
|
// this authorize() call can consume that entire 1-second margin and
|
||||||
|
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
||||||
|
// real-time race without touching lease-gate security semantics.
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||||
|
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
decision: 'allow',
|
decision: 'allow',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||||
|
// expiry demonstration below. It is never used for anything but the
|
||||||
|
// wait-then-expire assertion, so there is no setup work racing its
|
||||||
|
// 1-second window.
|
||||||
|
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||||
|
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
decision: 'deny',
|
decision: 'deny',
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||||
expect(
|
expect(
|
||||||
await request(socket, {
|
await request(socket, {
|
||||||
|
|||||||
@@ -217,12 +217,22 @@ git fetch origin
|
|||||||
mkdir -p ~/src/${projectName}-worktrees
|
mkdir -p ~/src/${projectName}-worktrees
|
||||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||||
|
pnpm install --frozen-lockfile --prefer-offline
|
||||||
# ... all work happens here ...
|
# ... all work happens here ...
|
||||||
git push origin <branch-name>
|
git push origin <branch-name>
|
||||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
|
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
|
||||||
|
|
||||||
|
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
|
||||||
|
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
|
||||||
|
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
|
||||||
|
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
|
||||||
|
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
|
||||||
|
until deps are installed. That failure is indistinguishable from a real
|
||||||
|
test/lint failure — a false-red gate. Never skip this step and never reorder
|
||||||
|
it after the first gate invocation.`);
|
||||||
|
|
||||||
// 6. Completion gates
|
// 6. Completion gates
|
||||||
sections.push(`# Completion Gates — ENFORCED
|
sections.push(`# Completion Gates — ENFORCED
|
||||||
|
|||||||
Reference in New Issue
Block a user