mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-01 11:06:03 +00:00
* fix(tooling): check changed exports for dead code
* fix(tooling): name mismatched PR wrapper components
* feat(tooling): repin PR review artifacts
* fix(tooling): scope dead-export detection to source files
Matching only the top-level tree let a docs file under src/ carrying `export`
in a code sample set the flag. That mattered because the flag also short-
circuited changedCheckRequiresRemote above its docsOnly guard, so a docs-only
change could be dragged onto the heavy remote route and made to run knip.
Scope detection to the source extensions knip actually reads, and drop the
routing branch entirely: any file that can now trigger detection already
enables a non-docs lane, which the existing final clause routes remotely. The
branch was dead for every real source change and wrong for the docs case.
* refactor(tooling): round-trip the repinned review JSON
Preserving byte-exact formatting required a hand-rolled JSON scanner: string
escape handling, container nesting, and span splicing, for a file that lives in
gitignored .local/ and is only ever read back through JSON.parse.
repin already parses the artifact to validate it, so assign the two identity
fields and re-serialize. JSON.parse/stringify keeps insertion order, which is
the only formatting property worth holding. Drops four helpers.
* fix(tooling): select the dead-export scan by path, not changed lines
Inspecting changed lines for an `export` token has two false negatives that
defeat the purpose. Barrels export through multiline `export { ... }` lists, so
removing a symbol changes a line carrying no `export` token. Worse, the failure
that motivated this scan was an import-only edit: it orphaned a re-export in a
barrel the diff never touched, which no changed-line rule can see.
Select by path instead: any changed production source file under src/,
extensions/, ui/, or packages/. This also removes the merge-base dependency, so
a shallow checkout or unrelated history can no longer silently skip the scan.
Those paths already enable a non-docs lane, so the run already routes remotely
and knip's cost lands on the box already doing the heavy work.
OPENCLAW_CHECK_CHANGED_SKIP_DEADCODE=1 still opts out.
* Revert "feat(tooling): repin PR review artifacts"
The repin subcommand defeats the property the identity pin exists to enforce.
It rewrites the pinned PR number and head SHA while keeping every
recommendation, finding, and Markdown conclusion, so a completed
"READY FOR /prepare-pr" verdict authored against one revision can be relabelled
as covering another and then pass validation. The test added alongside it
restamped PR #7 as PR #42 and asserted validation succeeded, which is exactly
the substitution the fail-closed gate was built to stop.
Re-authoring a review after the head moves is friction on purpose: the code the
verdict describes may no longer be the code being landed. A safe version has to
prove the reviewed content is equivalent across the move, which is a design
decision about what equivalence means, not a convenience wrapper.
Reverts 5d89a704d99 and its follow-up c6304eb5391.
* docs(tooling): note why the wrapper diagnostic reads HEAD blobs
Two reviewers independently read the new component comparison as inspecting
committed state while the refusal was triggered by working-tree files. The
uncommitted-wrapper guard above already exits for any staged or unstaged edit to
these three paths, so HEAD matches the working tree by the time this runs. State
the invariant at the site instead of letting a third reader re-derive it.
* fix(tooling): cover jsx in the dead-export source selector
The hand-written extension alternation matched .tsx but not .jsx, so a JSX
source change would skip a scan that knip does apply to it. Use the
`[cm]?[jt]sx?` selector the lint lanes in check-changed.mjs already use, which
covers both and is shorter, and pin the extension coverage in tests.
432 lines
14 KiB
Bash
Executable file
432 lines
14 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
# This wrapper parses GitHub CLI JSON. Caller shells may force ANSI color globally.
|
|
export NO_COLOR=1
|
|
export CLICOLOR=0
|
|
export CLICOLOR_FORCE=0
|
|
export FORCE_COLOR=0
|
|
unset COLORTERM
|
|
|
|
# This is the single source of truth for the canonical-wrapper trust boundary.
|
|
# Advisory commands may run a mismatched local wrapper only with the explicit
|
|
# developer opt-in; landing commands must always use canonical/origin-main code.
|
|
# Classification is independent of serialization: ci-dispatch remains locked
|
|
# because GitHub exposes neither dispatch deduplication nor a correlation ID.
|
|
# PR_SUBCOMMAND_CLASSIFICATIONS_BEGIN
|
|
pr_subcommand_classification() {
|
|
case "$1" in
|
|
ls | ci-dispatch)
|
|
printf 'advisory\n'
|
|
;;
|
|
gc | lock-recover | review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | review-tests | prepare-init | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run)
|
|
printf 'landing\n'
|
|
;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
# PR_SUBCOMMAND_CLASSIFICATIONS_END
|
|
|
|
dev_wrapper_opt_in=0
|
|
if [ "${OPENCLAW_PR_DEV_WRAPPER:-}" = "1" ]; then
|
|
dev_wrapper_opt_in=1
|
|
fi
|
|
if [ "${1-}" = "--dev-wrapper" ]; then
|
|
dev_wrapper_opt_in=1
|
|
export OPENCLAW_PR_DEV_WRAPPER=1
|
|
shift
|
|
fi
|
|
requested_subcommand="${1-}"
|
|
|
|
# If invoked from a linked worktree copy of this script, re-exec the canonical
|
|
# script from the repository root so behavior stays consistent across worktrees.
|
|
script_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
script_parent_dir="$(dirname "$script_self")"
|
|
if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
|
|
canonical_repo_root="$(dirname "$common_git_dir")"
|
|
canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")"
|
|
if [ "$script_self" != "$canonical_self" ] && [ -x "$canonical_self" ]; then
|
|
if ! git -C "$script_parent_dir" diff --quiet HEAD -- \
|
|
":(top)scripts/pr" ":(top)scripts/pr-lib" ":(top)scripts/lib/plain-gh.sh"; then
|
|
echo "scripts/pr wrapper files have uncommitted changes in this worktree." >&2
|
|
echo "Refusing to run unreviewed wrapper code from: $script_parent_dir" >&2
|
|
exit 1
|
|
fi
|
|
linked_wrapper_revision=$(
|
|
git -C "$script_parent_dir" rev-parse \
|
|
HEAD:scripts/pr \
|
|
HEAD:scripts/pr-lib \
|
|
HEAD:scripts/lib/plain-gh.sh 2>/dev/null || true
|
|
)
|
|
canonical_wrapper_revision=$(
|
|
git -C "$canonical_repo_root" rev-parse \
|
|
HEAD:scripts/pr \
|
|
HEAD:scripts/pr-lib \
|
|
HEAD:scripts/lib/plain-gh.sh 2>/dev/null || true
|
|
)
|
|
canonical_wrapper_clean=1
|
|
if ! git -C "$canonical_repo_root" diff --quiet HEAD -- \
|
|
":(top)scripts/pr" ":(top)scripts/pr-lib" ":(top)scripts/lib/plain-gh.sh"; then
|
|
canonical_wrapper_clean=0
|
|
fi
|
|
if [ -n "$linked_wrapper_revision" ] &&
|
|
[ "$linked_wrapper_revision" = "$canonical_wrapper_revision" ] &&
|
|
[ "$canonical_wrapper_clean" = "1" ]; then
|
|
exec "$canonical_self" "$@"
|
|
fi
|
|
# The canonical checkout can be parked on another branch or carry local
|
|
# edits (release trains move it); maintainer-controlled origin/main is the
|
|
# trust anchor then. Run THIS worktree's committed wrapper, without
|
|
# substitution, when it exactly matches origin/main.
|
|
# refs/remotes/... explicitly: bare "origin/main" is a DWIM name that a
|
|
# local branch or tag named origin/main could shadow, spoofing the anchor.
|
|
anchor_wrapper_revision=$(
|
|
git -C "$script_parent_dir" rev-parse \
|
|
refs/remotes/origin/main:scripts/pr \
|
|
refs/remotes/origin/main:scripts/pr-lib \
|
|
refs/remotes/origin/main:scripts/lib/plain-gh.sh 2>/dev/null || true
|
|
)
|
|
if [ -z "$linked_wrapper_revision" ] ||
|
|
[ -z "$anchor_wrapper_revision" ] ||
|
|
[ "$linked_wrapper_revision" != "$anchor_wrapper_revision" ]; then
|
|
requested_classification=$(pr_subcommand_classification "$requested_subcommand" 2>/dev/null || true)
|
|
if [ "$dev_wrapper_opt_in" = "1" ] && [ "$requested_classification" = "advisory" ]; then
|
|
if [ "${OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN:-}" != "1" ]; then
|
|
local_head_revision=$(git -C "$script_parent_dir" rev-parse HEAD 2>/dev/null || printf 'unknown')
|
|
echo "WARNING: running local scripts/pr revision $local_head_revision via dev-wrapper opt-in." >&2
|
|
echo "subcommand '$requested_subcommand' is classified advisory." >&2
|
|
echo "The local wrapper differs from the canonical checkout and origin/main; landing subcommands remain refused." >&2
|
|
export OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN=1
|
|
fi
|
|
else
|
|
if [ "$dev_wrapper_opt_in" = "1" ] && [ -n "$requested_classification" ]; then
|
|
echo "subcommand '$requested_subcommand' is classified $requested_classification; dev-wrapper opt-in is unavailable." >&2
|
|
fi
|
|
# HEAD blobs are authoritative here: the uncommitted-wrapper guard above
|
|
# already exited for any staged or unstaged edit to these three paths, so
|
|
# the working tree matches HEAD and this list matches what was rejected.
|
|
differing_wrapper_components=()
|
|
for wrapper_component in scripts/pr scripts/pr-lib scripts/lib/plain-gh.sh; do
|
|
linked_component_revision=$(git -C "$script_parent_dir" rev-parse "HEAD:$wrapper_component" 2>/dev/null || true)
|
|
anchor_component_revision=$(git -C "$script_parent_dir" rev-parse "refs/remotes/origin/main:$wrapper_component" 2>/dev/null || true)
|
|
if [ -z "$linked_component_revision" ] ||
|
|
[ -z "$anchor_component_revision" ] ||
|
|
[ "$linked_component_revision" != "$anchor_component_revision" ]; then
|
|
differing_wrapper_components+=("$wrapper_component")
|
|
fi
|
|
done
|
|
echo "scripts/pr implementation differs between this worktree and the canonical checkout, and does not match origin/main." >&2
|
|
echo "differing wrapper components vs origin/main: ${differing_wrapper_components[*]}" >&2
|
|
echo "Refusing to silently substitute canonical wrapper code from: $canonical_repo_root" >&2
|
|
echo "Run scripts/pr from a checkout whose wrapper matches the canonical checkout or a fetched origin/main." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
is_locked_pr_command() {
|
|
# Advisory trust classification permits local dogfood, but dispatches still
|
|
# serialize with landing operations because the remote mutation is not atomic.
|
|
if [ "$1" = "ci-dispatch" ]; then
|
|
return 0
|
|
fi
|
|
local classification
|
|
classification=$(pr_subcommand_classification "$1") || return 1
|
|
[ "$classification" = "landing" ] || return 1
|
|
# gc manages per-PR locks itself; lock-recover performs an exact-OID CAS.
|
|
[ "$1" != "gc" ] && [ "$1" != "lock-recover" ]
|
|
}
|
|
|
|
is_main_only_pr_command() {
|
|
case "$1" in
|
|
prepare-init | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
is_supervised_pr_process() {
|
|
[ "${OPENCLAW_PR_DEDICATED_PROCESS_GROUP:-}" = "1" ] &&
|
|
[ "${OPENCLAW_PR_LOCK_NOTIFY_FD:-}" = "3" ] &&
|
|
[ "${OPENCLAW_PR_LOCK_SUPERVISOR_PID:-}" = "$PPID" ]
|
|
}
|
|
|
|
if [ "${1-}" = "gc" ] || is_locked_pr_command "${1-}"; then
|
|
if is_supervised_pr_process; then
|
|
# Do not leak the one-shot marker to tools or nested wrapper calls.
|
|
unset OPENCLAW_PR_DEDICATED_PROCESS_GROUP
|
|
else
|
|
unset OPENCLAW_PR_DEDICATED_PROCESS_GROUP
|
|
unset OPENCLAW_PR_LOCK_NOTIFY_FD
|
|
unset OPENCLAW_PR_LOCK_SUPERVISOR_PID
|
|
command -v node >/dev/null 2>&1 || { echo "Missing required command: node" >&2; exit 1; }
|
|
exec node "$script_parent_dir/pr-lib/process-group-runner.mjs" "$script_parent_dir/.." "$script_self" "$@"
|
|
fi
|
|
fi
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/lib/plain-gh.sh"
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
Usage:
|
|
scripts/pr [--dev-wrapper] <subcommand> ...
|
|
scripts/pr ls
|
|
scripts/pr gc [--dry-run]
|
|
scripts/pr lock-recover <PR> <OWNER_OID> --confirmed-no-running-tools
|
|
scripts/pr review-init <PR>
|
|
scripts/pr review-checkout-main <PR>
|
|
scripts/pr review-checkout-pr <PR>
|
|
scripts/pr review-claim <PR>
|
|
scripts/pr review-guard <PR>
|
|
scripts/pr review-artifacts-init <PR>
|
|
scripts/pr review-validate-artifacts <PR>
|
|
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
|
|
scripts/pr prepare-init <PR>
|
|
scripts/pr prepare-validate-commit <PR>
|
|
scripts/pr prepare-gates <PR>
|
|
scripts/pr prepare-push <PR>
|
|
scripts/pr prepare-sync-head <PR>
|
|
scripts/pr prepare-run <PR>
|
|
scripts/pr ci-dispatch <PR>
|
|
scripts/pr merge-verify <PR>
|
|
scripts/pr merge-run <PR> [--auto-merge]
|
|
OPENCLAW_PR_MERGE_METHOD=merge|rebase preserves the PR commit series.
|
|
--auto-merge enables pinned squash auto-merge for a verified BEHIND head.
|
|
OPENCLAW_PR_AUTO_MERGE=1 is equivalent.
|
|
|
|
--dev-wrapper permits a mismatched local wrapper only for subcommands
|
|
classified advisory. OPENCLAW_PR_DEV_WRAPPER=1 is equivalent.
|
|
|
|
Required commands: git, gh, jq, rg (ripgrep), pnpm, node.
|
|
USAGE
|
|
}
|
|
|
|
require_cmds() {
|
|
local missing=()
|
|
local cmd
|
|
for cmd in git jq rg pnpm node; do
|
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
missing+=("$cmd")
|
|
fi
|
|
done
|
|
if ! OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)"; then
|
|
missing+=("gh")
|
|
else
|
|
export OPENCLAW_GH_BIN
|
|
fi
|
|
|
|
if [ "${#missing[@]}" -gt 0 ]; then
|
|
echo "Missing required command(s): ${missing[*]}" >&2
|
|
if [[ " ${missing[*]} " = *" rg "* ]]; then
|
|
echo "Install ripgrep and retry: https://github.com/BurntSushi/ripgrep#installation" >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
gh() {
|
|
gh_plain "$@"
|
|
}
|
|
|
|
require_main_target_pr() {
|
|
local pr="$1"
|
|
local base
|
|
base=$(gh pr view "$pr" --json baseRefName --jq .baseRefName)
|
|
if [ "$base" != "main" ]; then
|
|
echo "scripts/pr prepare and merge commands only support PRs targeting main; PR #$pr targets $base." >&2
|
|
echo "Use the reviewed release-branch landing flow for non-main PRs." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/worktree.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/operation-lock.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/common.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/changelog.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/gates.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/push.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/review.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/prepare-core.sh"
|
|
# shellcheck disable=SC1091
|
|
source "$script_parent_dir/pr-lib/merge.sh"
|
|
|
|
main() {
|
|
if [ "$#" -lt 1 ]; then
|
|
usage
|
|
exit 2
|
|
fi
|
|
|
|
local cmd="${1-}"
|
|
shift || true
|
|
|
|
if [ "$cmd" = "lock-recover" ]; then
|
|
local pr="${1-}"
|
|
local owner_oid="${2-}"
|
|
local confirmation="${3-}"
|
|
[ -n "$pr" ] && [ -n "$owner_oid" ] && [ "$#" -eq 3 ] || { usage; exit 2; }
|
|
recover_pr_operation_lock "$pr" "$owner_oid" "$confirmation"
|
|
return
|
|
fi
|
|
|
|
case "$cmd" in
|
|
ls) ;;
|
|
gc)
|
|
[ "$#" -eq 0 ] || { [ "$#" -eq 1 ] && [ "$1" = "--dry-run" ]; } || {
|
|
usage
|
|
exit 2
|
|
}
|
|
;;
|
|
review-tests)
|
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
|
;;
|
|
merge-run)
|
|
[ "$#" -ge 1 ] && [ "$#" -le 2 ] || { usage; exit 2; }
|
|
if [ "$#" -eq 2 ] && [ "$2" != "--auto-merge" ]; then
|
|
usage
|
|
exit 2
|
|
fi
|
|
;;
|
|
review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | prepare-init | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | ci-dispatch | merge-verify)
|
|
[ "$#" -ge 1 ] || { usage; exit 2; }
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
require_cmds
|
|
|
|
if is_main_only_pr_command "$cmd"; then
|
|
require_main_target_pr "${1-}"
|
|
fi
|
|
|
|
if is_locked_pr_command "$cmd"; then
|
|
local locked_pr="${1-}"
|
|
acquire_pr_operation_lock "$locked_pr"
|
|
begin_pr_operation_validation_phase
|
|
trap 'exit 129' HUP
|
|
trap 'exit 130' INT
|
|
trap 'exit 131' QUIT
|
|
trap 'exit 143' TERM
|
|
fi
|
|
|
|
case "$cmd" in
|
|
ls)
|
|
list_pr_worktrees
|
|
;;
|
|
gc)
|
|
local dry_run=false
|
|
if [ "$#" -eq 1 ]; then
|
|
dry_run=true
|
|
fi
|
|
gc_pr_worktrees "$dry_run"
|
|
;;
|
|
review-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_init "$pr"
|
|
;;
|
|
review-checkout-main)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_checkout_main "$pr"
|
|
;;
|
|
review-checkout-pr)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_checkout_pr "$pr"
|
|
;;
|
|
review-claim)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_claim "$pr"
|
|
;;
|
|
review-guard)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_guard "$pr"
|
|
;;
|
|
review-artifacts-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_artifacts_init "$pr"
|
|
;;
|
|
review-validate-artifacts)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
review_validate_artifacts "$pr"
|
|
;;
|
|
review-tests)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
shift || true
|
|
review_tests "$pr" "$@"
|
|
;;
|
|
prepare-init)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_init "$pr"
|
|
;;
|
|
prepare-validate-commit)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_validate_commit "$pr"
|
|
;;
|
|
prepare-gates)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_gates "$pr"
|
|
;;
|
|
prepare-push)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_push "$pr"
|
|
;;
|
|
prepare-sync-head)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_sync_head "$pr"
|
|
;;
|
|
prepare-run)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
prepare_run "$pr"
|
|
;;
|
|
ci-dispatch)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
ci_dispatch "$pr"
|
|
;;
|
|
merge-verify)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
merge_verify "$pr"
|
|
;;
|
|
merge-run)
|
|
local pr="${1-}"
|
|
[ -n "$pr" ] || { usage; exit 2; }
|
|
local auto_merge=false
|
|
if [ "${2-}" = "--auto-merge" ] || [ "${OPENCLAW_PR_AUTO_MERGE:-}" = "1" ]; then
|
|
auto_merge=true
|
|
fi
|
|
merge_run "$pr" "$auto_merge"
|
|
;;
|
|
*)
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|