Compare commits

...

18 Commits

Author SHA1 Message Date
Mason Huang
b2ac4f3e2c CI: trim CodeQL JavaScript scope 2026-04-25 09:48:12 +08:00
Mason Huang
122ba937ec Implement changelog section resolution and timeout suggestions
- Introduced `normalize_pr_changelog_section` and `resolve_pr_changelog_section` functions to determine the appropriate changelog section based on PR labels and titles, enhancing the handling of changelog entries.
- Added a timeout suggestion feature in `prepare_gates` to inform users of expected wait times based on the test mode.
- Updated various scripts to improve changelog validation and handling during the PR preparation and merge processes, ensuring better integration and user experience.
- Enhanced test coverage for changelog section resolution and prepare-push processes.
2026-04-21 18:16:00 +08:00
Mason Huang
7933d04ac8 PR flow: pick changelog section from PR title, dedupe by PR number
Two related gaps in the CHANGELOG write path both caused PRs to land
in the wrong place:

1) `resolve_merge_changelog_section` only consulted the environment
   override and PR labels. If a PR was only tagged with something
   like `size: S` (no `bug`/`fix`/`feature` label), the default fell
   through to Changes even when the PR title made the category
   obvious (e.g. `fix(cron): clean up deleteAfterRun direct
   deliveries` on PR #67807). That Conventional-Commits prefix is
   the repo's actual classification signal for most PRs and was
   being ignored.

   Add a title-prefix fallback after the label check: a title
   starting with `fix`, `bugfix`, or `hotfix` (followed by `(`, `:`,
   `!`, or whitespace) routes to Fixes; `feat`, `feature`, `enhance`
   route to Changes. Labels still win when present. Deliberately
   strict so `fixup!`, `fixing ...`, `fixture updates`, `featured
   posts` are not misread as `fix:`/`feat:`.

2) `appendUnreleasedChangelogEntry` deduped only on full-text
   equivalence. When a PR had a detailed entry written during
   prepare and then the merge step called `ensure_pr_changelog_entry`
   again with the short PR-title form, the two text bodies differed
   and the same PR got a duplicate line — that is what happened to
   PR #67679, which ended up with lines under both `### Changes` and
   `### Fixes`.

   Add a stronger precheck: scan the `## Unreleased` block for any
   existing bullet whose first PR reference matches the new entry's
   PR number. If one exists anywhere in the block (any sub-section),
   skip insertion. Dedup is scoped to Unreleased so the same PR
   number in a released block does not suppress a new Unreleased
   entry, and uses integer-equality on the PR number so `#67` is
   not shadowed by `#6767`.

Together these two changes cover both of the recently observed
failure modes:
- Missing section signal from labels (PR #67807) is now picked up
  from the title.
- Second ensure at merge time (PR #67679) no longer plants a
  duplicate in a different sub-section.

Covered by shell smoke (27 resolver scenarios) and vitest (12 cases
incl. the #67679 cross-section regression, released-block false
positive, and PR-number prefix collision).
2026-04-18 18:45:06 +08:00
Mason Huang
bdb94ab096 Enhance changelog and merge conflict handling
- Update `resolve_pr_changelog_entry` to support non-interactive contexts, allowing default entries in CI and other scenarios.
- Improve `mainline_drift_requires_sync` by checking for actual merge conflicts when overlapping files or critical infrastructure drift is detected, providing clearer feedback on whether a sync is required before merging.
2026-04-18 14:26:06 +08:00
Mason Huang
281e899f4b docs(agents): describe new changelog insertion policy
Align AGENTS.md with the ordered-insert-by-PR-number policy introduced
in 4f53718a36. The prior line required tail append-only; it is replaced
with a description of the new spreading policy and the tool that
enforces it.
2026-04-18 14:16:21 +08:00
Mason Huang
4f53718a36 PR flow: insert changelog entries sorted by PR number
Previously `appendUnreleasedChangelogEntry` always appended new entries
to the tail of the target subsection (### Changes / ### Fixes) under
## Unreleased. Every concurrent PR edited the same three-line context
window and collided on merge.

Change the insertion policy: extract the new entry's PR number, walk
the existing bullets, and slot in right before the first existing
bullet whose PR number is greater. If the new entry has no PR ref or
is the largest so far, fall back to tail-append. This spreads
concurrent PRs across the section body so two PRs only collide when
their numbers land adjacent.

Matching changes in the shell wrapper:

- `normalize_pr_changelog_entries` now uses the same ordered-insert
  when moving a misplaced entry back to ## Unreleased, instead of
  always appending at the tail.
- `validate_changelog_entry_for_pr` no longer enforces "entry must be
  at section tail". The previous heuristic was already obsolete; we
  deliberately do not replace it with a global ascending-order check
  either, because historical Unreleased content is time-ordered not
  PR-ordered and making PR ordering a hard postcondition would block
  every new PR behind a full-section rewrite. Ordering is an
  insertion strategy, not a repo invariant.

Covered by new vitest cases for the middle / head / tail / empty /
historical-unlinked scenarios.
2026-04-18 14:06:29 +08:00
Mason Huang
85b9fd25cf test(gates): run only changed-file tests in prepare gates
Use `pnpm test -- --changed origin/main` instead of the full suite
in both `prepare_gates` and `run_prepare_push_retry_gates`. The
`--changed` mechanism routes changed files to their specific test
lanes and automatically falls back to the full suite when
infrastructure files (package.json, vitest configs, etc.) are touched.

Set OPENCLAW_GATES_FULL_TEST=1 to force the full suite when needed.
The gates_mode field in .local/gates.env records "changed" or "full"
for auditability.
2026-04-16 14:05:14 +08:00
Mason Huang
483ab6a879 PR flow: preserve prepare artifacts on force-clean 2026-04-16 13:01:25 +08:00
Mason Huang
be910c78bd PR flow: refresh fork push metadata 2026-04-16 12:02:41 +08:00
Mason Huang
9b677116c8 PR flow: choose changelog sections 2026-04-16 11:58:57 +08:00
Mason Huang
196b04e0a6 PR flow: self-heal prepare sync 2026-04-16 11:55:13 +08:00
Mason Huang
7c146da949 PR flow: stabilize changelog validation 2026-04-16 11:50:31 +08:00
Mason Huang
81cd0424a5 PR flow: surface merge changelog failures 2026-04-16 11:44:28 +08:00
Mason Huang
f475e4192a PR flow: split QA infra from maintainer-only gating 2026-04-15 22:12:54 +08:00
Mason Huang
ac942d925c PR flow: remove merge confirmation prompt 2026-04-15 22:12:54 +08:00
Mason Huang
1fb81d1275 PR flow: smooth prepare edge cases 2026-04-15 22:12:54 +08:00
Mason Huang
f5722e2a69 PR flow: cap prepare rebases 2026-04-15 22:07:13 +08:00
Mason Huang
a1cdddd5dc PR flow: defer changelog to merge 2026-04-15 22:07:12 +08:00
23 changed files with 2074 additions and 179 deletions

View File

@@ -2,14 +2,16 @@
// Secret scanning alert handler for OpenClaw maintainers.
// Usage: node secret-scanning.mjs <command> [options]
import { execFileSync, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
const GH_BIN = process.env.OPENCLAW_SECRET_SCAN_GH_BIN || "gh";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -26,9 +28,11 @@ function tmpFile(purpose) {
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
const proc = spawnSync(GH_BIN, args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
fail(
`${GH_BIN} ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`,
);
}
if (proc.status !== 0) {
return {
@@ -201,6 +205,40 @@ function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
return result?.data?.addDiscussionComment?.comment;
}
function cmdSmoke() {
const bodyFile = tmpFile("smoke-body.md");
fs.writeFileSync(bodyFile, "redacted body\n", "utf8");
const summaryFile = tmpFile("smoke-summary.json");
fs.writeFileSync(
summaryFile,
JSON.stringify([
{
number: 12,
secret_type: "OpenAI API Key",
location_label: "PR comment",
location_url: `${REPO_URL}/pull/12#issuecomment-1200`,
actions: "comment redacted; author notified",
history_cleared: false,
},
{
number: 13,
secret_type: "AWS Access Key",
location_label: "Issue body",
location_url: `${REPO_URL}/issues/13`,
actions: "body redacted in place",
history_cleared: true,
},
]),
"utf8",
);
cmdNotify("12", "alice", "pull_request_comment", "OpenAI API Key,AWS Access Key");
cmdSummary(summaryFile);
console.error(`smoke-body-file=${bodyFile}`);
console.error(`smoke-summary-file=${summaryFile}`);
}
// ─── Commands ───────────────────────────────────────────────────────────────
/**
@@ -763,6 +801,7 @@ const commands = {
resolve: () => cmdResolve(args[0], args[1], args[2]),
"list-open": () => cmdListOpen(),
summary: () => cmdSummary(args[0]),
smoke: () => cmdSmoke(),
};
if (!command || !commands[command]) {
@@ -782,6 +821,7 @@ if (!command || !commands[command]) {
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
" smoke Run mock CLI smoke flow",
].join("\n"),
);
process.exit(1);

View File

@@ -12,6 +12,9 @@ paths-ignore:
- docs
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"

View File

@@ -224,7 +224,7 @@
- `pnpm test:live` defaults quiet now. Keep `[live]` progress; suppress profile/gateway chatter. Full logs: `OPENCLAW_LIVE_TEST_QUIET=0 pnpm test:live`.
- Full kit + whats covered: `docs/help/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
- Changelog placement: in the active version block, append new entries to the end of the target section (`### Changes` or `### Fixes`); do not insert new entries at the top of a section.
- Changelog placement: in the active version block, new entries go into the target section (`### Changes` or `### Fixes`). The automated wrapper (`scripts/changelog-add-unreleased.ts`) inserts by PR number ascending — find the first existing bullet whose PR number is greater than yours and slot in right before it; if yours is the largest, append to the end. This spreads concurrent PRs across the section body and reduces merge conflicts compared to always appending at the tail. Historical unordered entries are left alone; only the new entry is placed.
- Changelog attribution: use at most one contributor mention per line; prefer `Thanks @author` and do not also add `by @author` on the same entry.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.

View File

@@ -27,11 +27,12 @@ Usage:
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-init <PR> [--force-clean]
scripts/pr prepare-ack-unrelated <PR> <build|check|test> <reason> [scoped_verification]
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-sync-head <PR> [--force]
scripts/pr prepare-run <PR>
scripts/pr merge-verify <PR>
scripts/pr merge-run <PR>
@@ -41,7 +42,7 @@ USAGE
require_cmds() {
local missing=()
local cmd
for cmd in git gh jq rg pnpm node; do
for cmd in git gh jq rg pnpm node bun; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
@@ -136,7 +137,31 @@ main() {
prepare-init)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_init "$pr"
shift || true
local force_clean=false
while [ "$#" -gt 0 ]; do
case "$1" in
--force-clean)
force_clean=true
;;
*)
usage
exit 2
;;
esac
shift || true
done
prepare_init "$pr" "$force_clean"
;;
prepare-ack-unrelated)
local pr="${1-}"
local gate="${2-}"
local reason="${3-}"
local scoped_verification="${4-}"
[ -n "$pr" ] || { usage; exit 2; }
[ -n "$gate" ] || { usage; exit 2; }
[ -n "$reason" ] || { usage; exit 2; }
prepare_ack_unrelated "$pr" "$gate" "$reason" "$scoped_verification"
;;
prepare-validate-commit)
local pr="${1-}"
@@ -156,7 +181,21 @@ main() {
prepare-sync-head)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_sync_head "$pr"
shift || true
local force_rebase=false
while [ "$#" -gt 0 ]; do
case "$1" in
--force)
force_rebase=true
;;
*)
usage
exit 2
;;
esac
shift || true
done
prepare_sync_head "$pr" "$force_rebase"
;;
prepare-run)
local pr="${1-}"

View File

@@ -1,3 +1,150 @@
build_default_pr_changelog_entry() {
local pr="$1"
local contrib="$2"
local title="$3"
local trimmed_title
trimmed_title=$(printf '%s' "$title" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
if [ -z "$trimmed_title" ]; then
echo "Cannot build changelog entry: missing PR title."
exit 1
fi
if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
printf '%s (#%s). Thanks @%s\n' "$trimmed_title" "$pr" "$contrib"
return 0
fi
printf '%s (#%s).\n' "$trimmed_title" "$pr"
}
ensure_pr_changelog_entry() {
local pr="$1"
local contrib="$2"
local title="$3"
local section="${4:-Changes}"
local explicit_entry="${5:-}"
[ -f CHANGELOG.md ] || {
echo "CHANGELOG.md is missing."
exit 1
}
local entry
if [ -n "$explicit_entry" ]; then
entry="$explicit_entry"
else
entry=$(build_default_pr_changelog_entry "$pr" "$contrib" "$title")
fi
local before_hash
before_hash=$(sha256sum CHANGELOG.md | awk '{print $1}')
local changelog_output
changelog_output=$(bun scripts/changelog-add-unreleased.ts --section "${section,,}" "$entry")
echo "$changelog_output"
normalize_pr_changelog_entries "$pr"
validate_changelog_merge_hygiene
validate_changelog_entry_for_pr "$pr" "$contrib"
local after_hash
after_hash=$(sha256sum CHANGELOG.md | awk '{print $1}')
if [ "$before_hash" = "$after_hash" ]; then
echo "pr_changelog_changed=false"
else
echo "pr_changelog_changed=true"
fi
}
resolve_pr_changelog_entry() {
local pr="$1"
local contrib="$2"
local title="$3"
local default_entry
default_entry=$(build_default_pr_changelog_entry "$pr" "$contrib" "$title")
if [ -n "${OPENCLAW_PR_CHANGELOG_ENTRY:-}" ]; then
printf '%s\n' "$OPENCLAW_PR_CHANGELOG_ENTRY"
return 0
fi
# Non-interactive contexts (CI, pipe, or explicit opt-in) use the default entry
if [ ! -t 0 ] || [ -n "${CI:-}" ] || [ "${OPENCLAW_MERGE_NONINTERACTIVE:-}" = "1" ]; then
printf '%s\n' "$default_entry"
return 0
fi
echo "Default changelog entry:"
echo " $default_entry"
echo "Press Enter to accept, or paste a replacement single-line entry."
local answer
read -r answer
if [ -n "$answer" ]; then
printf '%s\n' "$answer"
return 0
fi
printf '%s\n' "$default_entry"
}
normalize_pr_changelog_section() {
local raw_section="${1:-Changes}"
local normalized
normalized=$(printf '%s' "$raw_section" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
fixes|fix)
printf '%s\n' "Fixes"
;;
changes|change|enhancement|feature)
printf '%s\n' "Changes"
;;
*)
echo "Unsupported changelog section override: $raw_section"
exit 1
;;
esac
}
resolve_pr_changelog_section() {
local pr_json="$1"
if [ -n "${OPENCLAW_PR_CHANGELOG_SECTION:-}" ]; then
normalize_pr_changelog_section "$OPENCLAW_PR_CHANGELOG_SECTION"
return 0
fi
local label_names
label_names=$(printf '%s\n' "$pr_json" | jq -r '.labels[]?.name // empty' | tr '[:upper:]' '[:lower:]')
if printf '%s\n' "$label_names" | rg -q '(^|[-_[:space:]])(bug|fix|bugfix|hotfix)([-_[:space:]]|$)'; then
printf '%s\n' "Fixes"
return 0
fi
if printf '%s\n' "$label_names" | rg -q '(^|[-_[:space:]])(feature|enhancement)([-_[:space:]]|$)'; then
printf '%s\n' "Changes"
return 0
fi
local pr_title_lower
pr_title_lower=$(printf '%s\n' "$pr_json" | jq -r '.title // empty' | tr '[:upper:]' '[:lower:]')
if [ -n "$pr_title_lower" ]; then
if printf '%s\n' "$pr_title_lower" | rg -q '^(fix|bugfix|hotfix)([[:space:]]*[(:!])'; then
printf '%s\n' "Fixes"
return 0
fi
if printf '%s\n' "$pr_title_lower" | rg -q '^(feat|feature|enhance|enhancement)([[:space:]]*[(:!])'; then
printf '%s\n' "Changes"
return 0
fi
fi
printf '%s\n' "Changes"
}
normalize_pr_changelog_entries() {
local pr="$1"
local changelog_path="CHANGELOG.md"
@@ -96,6 +243,38 @@ function sectionTailInsertIndex(arr, subsectionIndex) {
return insertAt;
}
function extractPrNumberFromLine(line) {
// 与 TS 侧 extractPrNumber 对齐:只取第一个 PR 引用作为排序键
const match = line.match(/(?:\(#(\d+)\)|openclaw#(\d+))/i);
const raw = match && (match[1] || match[2]);
if (!raw) {
return undefined;
}
const num = Number.parseInt(raw, 10);
return Number.isFinite(num) ? num : undefined;
}
function orderedInsertIndex(arr, subsectionIndex, nextHeading, newPr) {
// 无 PR 号时 fallback 到尾插,保持旧行为
if (newPr === undefined) {
return sectionTailInsertIndex(arr, subsectionIndex);
}
for (let i = subsectionIndex + 1; i < nextHeading; i += 1) {
const line = arr[i];
if (!/^- /.test(line)) {
continue;
}
const existing = extractPrNumberFromLine(line);
if (existing === undefined) {
continue;
}
if (existing > newPr) {
return i;
}
}
return sectionTailInsertIndex(arr, subsectionIndex);
}
ensureActiveSection(lines);
const moved = [];
@@ -123,7 +302,6 @@ const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx));
for (const entry of moved) {
const subsectionIndex = ensureSubsection(nextLines, entry.subsection);
const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex);
let nextHeading = nextLines.length;
for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) {
@@ -139,6 +317,9 @@ for (const entry of moved) {
if (alreadyPresent) {
continue;
}
const newPr = extractPrNumberFromLine(entry.line);
const insertAt = orderedInsertIndex(nextLines, subsectionIndex, nextHeading, newPr);
nextLines.splice(insertAt, 0, entry.line);
}
@@ -149,142 +330,102 @@ if (updated !== original) {
EOF_NODE
}
resolve_changelog_diff_range() {
local env_file
for env_file in .local/prep.env .local/prep-context.env; do
[ -s "$env_file" ] || continue
local candidate
candidate=$(
(
set +u
# shellcheck disable=SC1090
source "$env_file" >/dev/null 2>&1 || exit 0
printf '%s' "${PR_HEAD_SHA_BEFORE:-}"
)
)
if [ -n "$candidate" ] \
&& git cat-file -e "${candidate}^{commit}" 2>/dev/null \
&& git merge-base --is-ancestor "$candidate" HEAD 2>/dev/null; then
printf '%s\n' "${candidate}..HEAD"
return 0
fi
done
printf '%s\n' 'origin/main...HEAD'
}
validate_changelog_entry_for_pr() {
local pr="$1"
local contrib="$2"
local added_lines
added_lines=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
if [ -z "$added_lines" ]; then
echo "CHANGELOG.md is in diff but no added lines were detected."
exit 1
fi
local pr_pattern
pr_pattern="(#$pr|openclaw#$pr)"
local with_pr
with_pr=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" || true)
if [ -z "$with_pr" ]; then
echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))."
exit 1
fi
local diff_file
diff_file=$(mktemp)
git diff --unified=0 origin/main...HEAD -- CHANGELOG.md > "$diff_file"
if ! awk -v pr_pattern="$pr_pattern" '
# 只验证三件事:
# 1. 本 PR 条目存在于 ## Unreleased 之下
# 2. 条目落在某个 ### 子 section 里
# 3. 若有 contrib 信息,同一行含 `thanks @<contrib>`
#
# 不再对整个 section 做 PR 号全局单调性检查。PR 号升序只是插入策略
# (由 src/infra/changelog-unreleased.ts 执行),不是存量不变式 ——
# 历史 CHANGELOG 是按合并时间 append 的,本来就不严格升序,
# 把它当硬门会让所有新 PR 都被卡住。
local validation_output
if ! validation_output=$(awk -v pr_pattern="$pr_pattern" '
BEGIN {
line_no = 0
file_line_count = 0
current_release = ""
current_section = ""
issue_count = 0
}
FNR == NR {
if ($0 ~ /^@@ /) {
if (match($0, /\+[0-9]+/)) {
line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0
} else {
line_no = 0
}
next
}
if ($0 ~ /^\+\+\+/) {
next
}
if ($0 ~ /^\+/) {
if (line_no > 0) {
added[line_no] = 1
added_text = substr($0, 2)
if (added_text ~ pr_pattern) {
pr_added_lines[++pr_added_count] = line_no
pr_added_text[line_no] = added_text
}
line_no++
}
next
}
if ($0 ~ /^-/) {
next
}
if (line_no > 0) {
line_no++
}
next
pr_count = 0
}
{
changelog[FNR] = $0
file_line_count = FNR
if ($0 ~ /^## /) {
current_release = $0
current_section = ""
} else if ($0 ~ /^### /) {
current_section = $0
}
if ($0 ~ pr_pattern && current_release == "## Unreleased") {
pr_lines[++pr_count] = FNR
pr_text[FNR] = $0
pr_sections[FNR] = current_section
}
}
END {
for (idx = 1; idx <= pr_added_count; idx++) {
entry_line = pr_added_lines[idx]
release_line = 0
section_line = 0
for (i = entry_line; i >= 1; i--) {
if (section_line == 0 && changelog[i] ~ /^### /) {
section_line = i
continue
}
if (changelog[i] ~ /^## /) {
release_line = i
break
}
}
if (release_line == 0 || changelog[release_line] != "## Unreleased") {
printf "CHANGELOG.md PR-linked entry must be in ## Unreleased: line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
if (section_line == 0) {
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
if (pr_count == 0) {
printf "CHANGELOG.md update must reference PR pattern %s inside ## Unreleased.\n", pr_pattern
exit 1
}
section_name = changelog[section_line]
next_heading = file_line_count + 1
for (i = entry_line + 1; i <= file_line_count; i++) {
if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) {
next_heading = i
break
}
}
for (i = entry_line + 1; i < next_heading; i++) {
line_text = changelog[i]
if (line_text ~ /^[[:space:]]*$/) {
continue
}
if (i in added) {
continue
}
printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line]
printf "Found existing non-added line below it at line %d: %s\n", i, line_text
for (idx = 1; idx <= pr_count; idx++) {
entry_line = pr_lines[idx]
if (pr_sections[entry_line] == "") {
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_text[entry_line]
issue_count++
break
}
}
if (issue_count > 0) {
print "Move this PR changelog entry to the end of its section (just before the next heading)."
exit 1
}
print "changelog placement validated: PR-linked entry exists under ## Unreleased in a subsection"
}
' "$diff_file" CHANGELOG.md; then
rm -f "$diff_file"
' CHANGELOG.md); then
printf '%s\n' "$validation_output"
exit 1
fi
rm -f "$diff_file"
echo "changelog placement validated: PR-linked entries are appended at section tail"
printf '%s\n' "$validation_output"
if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
local with_pr_and_thanks
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true)
with_pr_and_thanks=$(awk -v pr_pattern="$pr_pattern" '
/^## / { current_release = $0 }
current_release == "## Unreleased" && $0 ~ pr_pattern { print }
' CHANGELOG.md | rg -i "thanks @$contrib" || true)
if [ -z "$with_pr_and_thanks" ]; then
echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line."
exit 1
@@ -297,8 +438,11 @@ END {
}
validate_changelog_merge_hygiene() {
local diff_range
diff_range=$(resolve_changelog_diff_range)
local diff
diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md)
diff=$(git diff --unified=0 "$diff_range" -- CHANGELOG.md)
local removed_lines
removed_lines=$(printf '%s\n' "$diff" | awk '

View File

@@ -26,6 +26,16 @@ path_is_testish() {
return 1
}
path_is_qa_infra_only() {
local path="$1"
case "$path" in
extensions/qa-channel/*|extensions/qa-lab/*)
return 0
;;
esac
return 1
}
path_is_maintainer_workflow_only() {
local path="$1"
case "$path" in
@@ -58,7 +68,12 @@ changelog_required_for_changed_files() {
while IFS= read -r path; do
[ -n "$path" ] || continue
saw_any=true
if path_is_docsish "$path" || path_is_testish "$path" || path_is_maintainer_workflow_only "$path"; then
if \
path_is_docsish "$path" || \
path_is_testish "$path" || \
path_is_qa_infra_only "$path" || \
path_is_maintainer_workflow_only "$path"
then
continue
fi
return 0
@@ -301,15 +316,15 @@ remove_worktree_if_present() {
return 0
fi
if command -v trash >/dev/null 2>&1; then
trash "$path" >/dev/null 2>&1 || {
echo "Warning: failed to trash orphaned worktree dir $path"
return 0
}
rm -rf "$path" >/dev/null 2>&1 || {
echo "Warning: failed to remove orphaned worktree dir $path"
return 0
}
if [ -e "$path" ]; then
echo "Warning: orphaned worktree dir remains after cleanup attempt: $path"
fi
echo "Warning: orphaned worktree dir remains and trash is unavailable: $path"
return 0
}

View File

@@ -1,3 +1,143 @@
normalize_prepare_gate_key() {
local gate="$1"
case "$gate" in
build|check|test)
printf '%s\n' "$gate"
;;
*)
echo "Unsupported gate '$gate'. Expected one of: build, check, test."
exit 2
;;
esac
}
prepare_gate_ack_file() {
printf '.local/gates-ack.json\n'
}
prepare_ack_unrelated() {
local pr="$1"
local gate
gate=$(normalize_prepare_gate_key "$2")
local reason="$3"
local scoped_verification="${4:-}"
enter_worktree "$pr" false
checkout_prep_branch "$pr"
local head_sha
head_sha=$(git rev-parse HEAD)
local ack_file
ack_file=$(prepare_gate_ack_file)
mkdir -p .local
if [ ! -f "$ack_file" ]; then
printf '[]\n' > "$ack_file"
fi
local tmp_file
tmp_file=$(mktemp)
jq \
--arg gate "$gate" \
--arg head_sha "$head_sha" \
--arg reason "$reason" \
--arg scoped_verification "$scoped_verification" \
--arg acknowledged_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'
[ .[]
| select(.gate != $gate or .head_sha != $head_sha)
] + [
{
gate: $gate,
head_sha: $head_sha,
reason: $reason,
scoped_verification: $scoped_verification,
acknowledged_at: $acknowledged_at
}
]
' "$ack_file" > "$tmp_file"
mv "$tmp_file" "$ack_file"
echo "Recorded unrelated baseline gate acknowledgement."
echo "gate=$gate"
echo "head_sha=$head_sha"
echo "reason=$reason"
if [ -n "$scoped_verification" ]; then
echo "scoped_verification=$scoped_verification"
fi
echo "wrote=$ack_file"
}
prepare_gate_ack_summary() {
local gate="$1"
local head_sha="$2"
local ack_file
ack_file=$(prepare_gate_ack_file)
if [ ! -s "$ack_file" ]; then
return 1
fi
jq -r \
--arg gate "$gate" \
--arg head_sha "$head_sha" \
'
first(
.[]
| select(.gate == $gate and .head_sha == $head_sha)
| [(.reason // ""), (.scoped_verification // "")]
| map(select(length > 0))
| join(" | ")
) // empty
' "$ack_file"
}
prepare_gate_is_acknowledged_for_head() {
local gate="$1"
local head_sha="$2"
local ack_file
ack_file=$(prepare_gate_ack_file)
if [ ! -s "$ack_file" ]; then
return 1
fi
jq -e \
--arg gate "$gate" \
--arg head_sha "$head_sha" \
'any(.[]; .gate == $gate and .head_sha == $head_sha)' \
"$ack_file" >/dev/null
}
run_prepare_gate_with_ack() {
local pr="$1"
local gate="$2"
local head_sha="$3"
local label="$4"
local log_file="$5"
shift 5
PREPARE_GATE_LAST_STATUS=""
if prepare_gate_is_acknowledged_for_head "$gate" "$head_sha"; then
PREPARE_GATE_LAST_STATUS="acknowledged_baseline"
echo "$label acknowledged as unrelated baseline noise for head $head_sha"
local ack_summary
ack_summary=$(prepare_gate_ack_summary "$gate" "$head_sha" || true)
if [ -n "$ack_summary" ]; then
echo "ack=$ack_summary"
fi
return 0
fi
if run_quiet_logged "$label" "$log_file" "$@"; then
PREPARE_GATE_LAST_STATUS="passed"
return 0
fi
echo "To acknowledge this as unrelated baseline noise for the current prep head, run:"
echo " scripts/pr prepare-ack-unrelated $pr $gate \"reason\" \"scoped verification\""
return 1
}
run_prepare_push_retry_gates() {
local docs_only="${1:-false}"
@@ -5,7 +145,11 @@ run_prepare_push_retry_gates() {
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
if [ "$docs_only" != "true" ]; then
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
if [ "${OPENCLAW_GATES_FULL_TEST:-}" = "1" ]; then
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
else
run_quiet_logged "pnpm test --changed (lease-retry)" ".local/lease-retry-test.log" pnpm test -- --changed origin/main
fi
fi
}
@@ -16,6 +160,18 @@ prepare_gates() {
checkout_prep_branch "$pr"
bootstrap_deps_if_needed
require_artifact .local/pr-meta.env
# Emit a machine-readable (and human-readable) timeout suggestion up-front
# so callers know how long to wait before considering a gate stuck.
local suggested_timeout_ms
if [ "${OPENCLAW_GATES_FULL_TEST:-}" = "1" ]; then
suggested_timeout_ms=2700000 # 45 minutes
else
suggested_timeout_ms=900000 # 15 minutes
fi
echo "Gate timeout suggestion: ${suggested_timeout_ms} ms"
echo "suggested_timeout_ms=${suggested_timeout_ms}"
# shellcheck disable=SC1091
source .local/pr-meta.env
@@ -53,19 +209,16 @@ prepare_gates() {
exit 1
fi
if [ "$changelog_required" = "true" ] && [ "$has_changelog_update" = "false" ]; then
echo "Missing changelog update. Add CHANGELOG.md changes."
exit 1
fi
if [ "$has_changelog_update" = "true" ]; then
normalize_pr_changelog_entries "$pr"
fi
if [ "$changelog_required" = "true" ]; then
if [ "$has_changelog_update" = "true" ]; then
local contrib="${PR_AUTHOR:-}"
validate_changelog_merge_hygiene
validate_changelog_entry_for_pr "$pr" "$contrib"
elif [ "$changelog_required" = "true" ]; then
echo "Changelog is required for this PR and will be added during prepare if still missing."
else
echo "Changelog not required for this changed-file set."
fi
@@ -74,15 +227,24 @@ prepare_gates() {
current_head=$(git rev-parse HEAD)
local previous_last_verified_head=""
local previous_full_gates_head=""
local previous_build_gate_status=""
local previous_check_gate_status=""
local previous_test_gate_status=""
if [ -s .local/gates.env ]; then
# shellcheck disable=SC1091
source .local/gates.env
previous_last_verified_head="${LAST_VERIFIED_HEAD_SHA:-}"
previous_full_gates_head="${FULL_GATES_HEAD_SHA:-}"
previous_build_gate_status="${BUILD_GATE_STATUS:-}"
previous_check_gate_status="${CHECK_GATE_STATUS:-}"
previous_test_gate_status="${TEST_GATE_STATUS:-}"
fi
local gates_mode="full"
local reuse_gates=false
local build_gate_status=""
local check_gate_status=""
local test_gate_status=""
if [ "$docs_only" = "true" ] && [ -n "$previous_last_verified_head" ] && git merge-base --is-ancestor "$previous_last_verified_head" HEAD 2>/dev/null; then
local delta_since_verified
delta_since_verified=$(git diff --name-only "$previous_last_verified_head"..HEAD)
@@ -93,36 +255,66 @@ prepare_gates() {
if [ "$reuse_gates" = "true" ]; then
gates_mode="reused_docs_only"
build_gate_status="${previous_build_gate_status:-reused_previous}"
check_gate_status="${previous_check_gate_status:-reused_previous}"
test_gate_status="${previous_test_gate_status:-reused_docs_only}"
echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates."
else
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
run_prepare_gate_with_ack "$pr" build "$current_head" "pnpm build" ".local/gates-build.log" pnpm build
build_gate_status="$PREPARE_GATE_LAST_STATUS"
run_prepare_gate_with_ack "$pr" check "$current_head" "pnpm check" ".local/gates-check.log" pnpm check
check_gate_status="$PREPARE_GATE_LAST_STATUS"
if [ "$docs_only" = "true" ]; then
gates_mode="docs_only"
test_gate_status="skipped_docs_only"
echo "Docs-only change detected with high confidence; skipping pnpm test."
else
gates_mode="full"
local test_args=(pnpm test)
if [ "${OPENCLAW_GATES_FULL_TEST:-}" != "1" ]; then
test_args+=(-- --changed origin/main)
gates_mode="changed"
echo "Running pnpm test --changed origin/main (set OPENCLAW_GATES_FULL_TEST=1 to force full suite)."
else
gates_mode="full"
echo "Running full pnpm test (OPENCLAW_GATES_FULL_TEST=1)."
fi
if [ -n "${OPENCLAW_VITEST_MAX_WORKERS:-}" ]; then
echo "Running pnpm test with OPENCLAW_VITEST_MAX_WORKERS=$OPENCLAW_VITEST_MAX_WORKERS."
run_quiet_logged \
run_prepare_gate_with_ack \
"$pr" \
test \
"$current_head" \
"pnpm test" \
".local/gates-test.log" \
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" pnpm test
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" "${test_args[@]}"
else
echo "Running pnpm test with host-aware scheduling defaults."
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
run_prepare_gate_with_ack \
"$pr" \
test \
"$current_head" \
"pnpm test" \
".local/gates-test.log" \
"${test_args[@]}"
fi
test_gate_status="$PREPARE_GATE_LAST_STATUS"
previous_full_gates_head="$current_head"
fi
fi
if [ "$build_gate_status" = "acknowledged_baseline" ] || [ "$check_gate_status" = "acknowledged_baseline" ] || [ "$test_gate_status" = "acknowledged_baseline" ]; then
gates_mode="${gates_mode}_with_acknowledged_baseline"
fi
# Security: shell-escape values to prevent command injection when sourced.
printf '%s=%q\n' \
PR_NUMBER "$pr" \
DOCS_ONLY "$docs_only" \
CHANGELOG_REQUIRED "$changelog_required" \
GATES_MODE "$gates_mode" \
BUILD_GATE_STATUS "$build_gate_status" \
CHECK_GATE_STATUS "$check_gate_status" \
TEST_GATE_STATUS "$test_gate_status" \
LAST_VERIFIED_HEAD_SHA "$current_head" \
FULL_GATES_HEAD_SHA "${previous_full_gates_head:-}" \
GATES_PASSED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
@@ -131,5 +323,8 @@ prepare_gates() {
echo "docs_only=$docs_only"
echo "changelog_required=$changelog_required"
echo "gates_mode=$gates_mode"
echo "build_gate_status=$build_gate_status"
echo "check_gate_status=$check_gate_status"
echo "test_gate_status=$test_gate_status"
echo "wrote=.local/gates.env"
}

View File

@@ -70,8 +70,26 @@ mainline_drift_requires_sync() {
return 0
fi
# When overlapping files or critical infra drift exist, check for actual merge
# conflicts first. Overlapping files != merge conflicts; Git can auto-merge
# in many cases, so only block when real conflicts are present.
if [ "$overlap_count" -gt 0 ] || [ "$critical_count" -gt 0 ]; then
echo "Mainline drift relevance: sync required before merge."
local merge_base
merge_base=$(git merge-base "$prep_head_sha" origin/main 2>/dev/null || true)
if [ -n "$merge_base" ]; then
local conflict_count
conflict_count=$(git merge-tree "$merge_base" "$prep_head_sha" origin/main 2>/dev/null | grep -c "^<<<<<<<" || true)
if [ "$conflict_count" -eq 0 ]; then
echo "Mainline drift relevance: overlapping files detected but no merge conflicts; safe to merge without sync."
print_file_list_with_limit "Mainline files overlapping PR touched files" "$overlap_file"
print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file"
rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file"
return 1
fi
echo "Mainline drift relevance: $conflict_count merge conflict(s) detected; sync required before merge."
else
echo "Mainline drift relevance: unable to compute merge base; sync required before merge."
fi
print_file_list_with_limit "Mainline files overlapping PR touched files" "$overlap_file"
print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file"
rm -f "$delta_file" "$pr_files_file" "$overlap_file" "$critical_file"
@@ -164,12 +182,53 @@ merge_verify() {
echo "merge-verify passed for PR #$pr"
}
refresh_merge_prep_metadata() {
local pr="$1"
local prep_head_sha="$2"
local pushed_from_sha="$3"
local contrib="$4"
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id)
local coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
printf '%s=%q\n' \
PR_NUMBER "$pr" \
PR_AUTHOR "$contrib" \
PR_URL "${PR_URL:-}" \
PR_HEAD "$PR_HEAD" \
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
PREP_HEAD_SHA "$prep_head_sha" \
COAUTHOR_EMAIL "$coauthor_email" \
> .local/prep.env
}
run_merge_changelog_with_diagnostics() {
local changelog_result=""
if ! changelog_result=$(ensure_pr_changelog_entry "$@"); then
if [ -n "$changelog_result" ]; then
printf '%s\n' "$changelog_result" >&2
fi
return 1
fi
printf '%s\n' "$changelog_result"
}
write_merge_prep_log_entry() {
local changelog_status="$1"
cat >> .local/prep.md <<EOF_PREP
- Merge-stage changelog status: $changelog_status.
- Merge is ready to execute the deterministic gh pr merge step.
EOF_PREP
}
merge_run() {
local pr="$1"
enter_worktree "$pr" false
local required
for required in .local/review.md .local/review.json .local/prep.md .local/prep.env; do
for required in .local/review.md .local/review.json .local/prep.md .local/prep.env .local/gates.env; do
require_artifact "$required"
done
@@ -178,7 +237,7 @@ merge_run() {
source .local/prep.env
local pr_meta_json
pr_meta_json=$(gh pr view "$pr" --json number,title,state,isDraft,author)
pr_meta_json=$(gh pr view "$pr" --json number,title,state,isDraft,author,labels)
local pr_title
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
local pr_number
@@ -217,6 +276,16 @@ merge_run() {
local reviewer_email="${reviewer_email_candidates[0]}"
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
local changelog_status="not_required"
# Prepare owns the authoritative changelog-required decision.
# shellcheck disable=SC1091
source .local/gates.env
if [ "${CHANGELOG_REQUIRED:-false}" = "true" ]; then
validate_changelog_entry_for_pr "$pr" "$contrib"
changelog_status="prepared"
fi
write_merge_prep_log_entry "$changelog_status"
cat > .local/merge-body.txt <<EOF_BODY
Merged via squash.
@@ -252,6 +321,11 @@ EOF_BODY
return 0
fi
if ! gh api "repos/$repo_owner/$repo_name/git/ref/$encoded_ref" >/dev/null 2>&1; then
echo "Remote branch cleanup: branch already absent for $repo_owner/$repo_name:$head_ref"
return 0
fi
echo "Warning: failed to delete remote branch $repo_owner/$repo_name:$head_ref"
return 0
}

View File

@@ -49,7 +49,8 @@ verify_prep_branch_matches_prepared_head() {
prepare_init() {
local pr="$1"
enter_worktree "$pr" true
local force_clean="${2:-false}"
enter_worktree "$pr" true "$force_clean"
require_artifact .local/pr-meta.env
require_artifact .local/review.md
@@ -84,6 +85,7 @@ prepare_init() {
PR_HEAD "$head" \
PR_HEAD_SHA_BEFORE "$pr_head_sha_before" \
PREP_BRANCH "pr-$pr-prep" \
PREP_REBASE_COUNT 0 \
PREP_STARTED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> .local/prep-context.env
@@ -100,6 +102,17 @@ EOF_PREP
echo "wrote=.local/prep-context.env .local/prep.md"
}
prepare_sync_rebase_allowed() {
local rebase_count="$1"
local force_rebase="${2:-false}"
if [ "$rebase_count" -ge 1 ] && [ "$force_rebase" != "true" ]; then
return 1
fi
return 0
}
prepare_validate_commit() {
local pr="$1"
enter_worktree "$pr" false
@@ -144,6 +157,40 @@ prepare_push() {
# shellcheck disable=SC1091
source .local/gates.env
local prep_pr_json=""
local pr_title=""
local contrib="${PR_AUTHOR:-}"
if [ "${CHANGELOG_REQUIRED:-false}" = "true" ] || [ -z "$contrib" ]; then
prep_pr_json=$(pr_meta_json "$pr")
pr_title=$(printf '%s\n' "$prep_pr_json" | jq -r '.title // ""')
if [ -z "$contrib" ]; then
contrib=$(printf '%s\n' "$prep_pr_json" | jq -r '.author.login // ""')
fi
fi
local changelog_status="not_required"
if [ "${CHANGELOG_REQUIRED:-false}" = "true" ]; then
local resolved_changelog_entry
resolved_changelog_entry=$(resolve_pr_changelog_entry "$pr" "$contrib" "$pr_title")
local changelog_section
changelog_section=$(resolve_pr_changelog_section "$prep_pr_json")
local changelog_result
if ! changelog_result=$(ensure_pr_changelog_entry "$pr" "$contrib" "$pr_title" "$changelog_section" "$resolved_changelog_entry"); then
echo "Changelog validation failed during prepare-push." >&2
exit 1
fi
echo "$changelog_result"
if printf '%s\n' "$changelog_result" | rg -q '^pr_changelog_changed=true$'; then
local commit_msg
commit_msg=$(printf '%s' "$pr_title" | sed 's/[[:space:]]\+$//')
scripts/committer --fast "$commit_msg" CHANGELOG.md
changelog_status="added_and_committed"
else
changelog_status="already_present"
fi
fi
local prep_head_sha
prep_head_sha=$(git rev-parse HEAD)
@@ -158,8 +205,8 @@ prepare_push() {
prep_head_sha="$PUSH_PREP_HEAD_SHA"
local pushed_from_sha="$PUSHED_FROM_SHA"
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
local push_main_status="${PUSH_MAIN_STATUS:-up_to_date}"
local contrib="${PR_AUTHOR:-}"
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
@@ -168,11 +215,23 @@ prepare_push() {
local coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
cat >> .local/prep.md <<EOF_PREP
- Prepare-stage changelog status: $changelog_status.
- Gates passed and push succeeded to branch $PR_HEAD.
- Gate mode: ${GATES_MODE:-unknown}.
- Gate statuses: build=${BUILD_GATE_STATUS:-unknown}, check=${CHECK_GATE_STATUS:-unknown}, test=${TEST_GATE_STATUS:-unknown}.
- Verified PR head SHA matches local prep HEAD.
EOF_PREP
if [ "$push_main_status" = "main_advanced_after_push" ]; then
cat >> .local/prep.md <<EOF_PREP
- Push succeeded, but origin/main advanced again before post-push freshness verification.
- Use scripts/pr prepare-sync-head $pr for the next lightweight sync/push cycle if the new mainline drift is relevant.
EOF_PREP
else
cat >> .local/prep.md <<'EOF_PREP'
- Verified PR head contains origin/main.
EOF_PREP
fi
# Security: shell-escape values to prevent command injection via propagated PR_HEAD.
printf '%s=%q\n' \
@@ -192,11 +251,13 @@ EOF_PREP
echo "prep_branch=$(git branch --show-current)"
echo "prep_head_sha=$prep_head_sha"
echo "pr_head_sha=$pr_head_sha_after"
echo "post_push_main_status=$push_main_status"
echo "artifacts=.local/prep.md .local/prep.env"
}
prepare_sync_head() {
local pr="$1"
local force_rebase="${2:-false}"
enter_worktree "$pr" false
require_artifact .local/pr-meta.env
@@ -212,8 +273,26 @@ prepare_sync_head() {
local rebased=false
git fetch origin main
if ! git merge-base --is-ancestor origin/main HEAD; then
local rebase_count="${PREP_REBASE_COUNT:-0}"
if ! prepare_sync_rebase_allowed "$rebase_count" "$force_rebase"; then
echo "prepare-sync-head already rebased this prep branch once; stop here and merge from the current prepared head, or re-run with --force if another rebase is intentional."
exit 1
fi
if [ "$rebase_count" -ge 1 ] && [ "$force_rebase" = "true" ]; then
echo "prepare-sync-head --force: allowing another rebase after PREP_REBASE_COUNT=$rebase_count."
fi
git rebase origin/main
rebased=true
rebase_count=$((rebase_count + 1))
printf '%s=%q\n' \
PR_NUMBER "$PR_NUMBER" \
PR_HEAD "$PR_HEAD" \
PR_HEAD_SHA_BEFORE "${PR_HEAD_SHA_BEFORE:-}" \
PREP_BRANCH "$PREP_BRANCH" \
PREP_REBASE_COUNT "$rebase_count" \
PREP_STARTED_AT "$PREP_STARTED_AT" \
> .local/prep-context.env
prepare_gates "$pr"
checkout_prep_branch "$pr"
fi
@@ -232,6 +311,7 @@ prepare_sync_head() {
prep_head_sha="$PUSH_PREP_HEAD_SHA"
local pushed_from_sha="$PUSHED_FROM_SHA"
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
local push_main_status="${PUSH_MAIN_STATUS:-up_to_date}"
local contrib="${PR_AUTHOR:-}"
if [ -z "$contrib" ]; then
@@ -244,11 +324,22 @@ prepare_sync_head() {
cat >> .local/prep.md <<EOF_PREP
- Prep head sync completed to branch $PR_HEAD.
- Rebased onto origin/main: $rebased.
- Prepare sync rebase count: ${PREP_REBASE_COUNT:-0}.
- Verified PR head SHA matches local prep HEAD.
- Verified PR head contains origin/main.
- Prepare gates reran automatically when the sync rebase changed the prep head.
EOF_PREP
if [ "$push_main_status" = "main_advanced_after_push" ]; then
cat >> .local/prep.md <<EOF_PREP
- Push succeeded, but origin/main advanced again before post-push freshness verification.
- Another full prepare rerun is not required; use scripts/pr prepare-sync-head $pr again only if that drift is relevant.
EOF_PREP
else
cat >> .local/prep.md <<'EOF_PREP'
- Verified PR head contains origin/main.
EOF_PREP
fi
# Security: shell-escape values to prevent command injection via propagated PR_HEAD.
printf '%s=%q\n' \
PR_NUMBER "$PR_NUMBER" \
@@ -267,6 +358,7 @@ EOF_PREP
echo "prep_branch=$(git branch --show-current)"
echo "prep_head_sha=$prep_head_sha"
echo "pr_head_sha=$pr_head_sha_after"
echo "post_push_main_status=$push_main_status"
echo "artifacts=.local/prep.md .local/prep.env"
}

View File

@@ -166,7 +166,20 @@ verify_pr_head_branch_matches_expected() {
fi
}
refresh_pr_head_push_metadata() {
local pr="$1"
local json
json=$(pr_meta_json "$pr")
mkdir -p .local
write_pr_meta_files "$json"
}
setup_prhead_remote() {
local pr="${1:-}"
if [ -n "$pr" ]; then
refresh_pr_head_push_metadata "$pr"
fi
local push_url
push_url=$(resolve_head_push_url) || {
echo "Unable to resolve PR head repo push URL."
@@ -211,7 +224,7 @@ push_prep_head_to_pr_branch() {
local docs_only="${6:-false}"
local result_env_path="${7:-.local/push-result.env}"
setup_prhead_remote
setup_prhead_remote "$pr"
local remote_sha
remote_sha=$(resolve_prhead_remote_sha "$pr_head")
@@ -281,17 +294,22 @@ push_prep_head_to_pr_branch() {
local pr_head_sha_after
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
local push_main_status="up_to_date"
local push_main_verified_sha=""
git fetch origin main
push_main_verified_sha=$(git rev-parse origin/main)
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
echo "PR branch is behind main after push."
exit 1
}
if ! git merge-base --is-ancestor origin/main "pr-$pr-verify"; then
push_main_status="main_advanced_after_push"
echo "PR branch accepted the push, but origin/main advanced again before post-push freshness verification."
fi
git branch -D "pr-$pr-verify" 2>/dev/null || true
# Security: shell-escape values to prevent command injection when sourced.
printf '%s=%q\n' \
PUSH_PREP_HEAD_SHA "$prep_head_sha" \
PUSHED_FROM_SHA "$pushed_from_sha" \
PR_HEAD_SHA_AFTER_PUSH "$pr_head_sha_after" \
PUSH_MAIN_STATUS "$push_main_status" \
PUSH_MAIN_VERIFIED_SHA "$push_main_verified_sha" \
> "$result_env_path"
}

View File

@@ -152,7 +152,7 @@ F) Tests
G) Docs status
H) Changelog
H) Prepare-stage changelog handoff
I) Follow ups (optional)
@@ -188,8 +188,7 @@ EOF_MD
"gaps": [],
"result": "pass"
},
"docs": "not_applicable",
"changelog": "not_required"
"docs": "not_applicable"
}
EOF_JSON
fi
@@ -429,17 +428,6 @@ review_validate_artifacts() {
;;
esac
local changelog_status
changelog_status=$(jq -r '.changelog // ""' .local/review.json)
case "$changelog_status" in
"required"|"not_required")
;;
*)
echo "Invalid changelog status in .local/review.json: $changelog_status (must be \"required\" or \"not_required\")"
exit 1
;;
esac
echo "review artifacts validated"
print_review_stdout_summary
}

View File

@@ -31,6 +31,7 @@ EOF
enter_worktree() {
local pr="$1"
local reset_to_main="${2:-false}"
local force_clean="${3:-false}"
local invoke_cwd
invoke_cwd="$PWD"
local root
@@ -40,12 +41,20 @@ enter_worktree() {
echo "Detected non-root invocation cwd=$invoke_cwd, using canonical root $root"
fi
if [ -d .local ] && [ -s .local/review-mode.env ] && [ ! -e .local/pr-meta.env ]; then
echo "Refusing to continue from a review-mode worktree with missing PR metadata. Re-run scripts/pr review-init <PR> from repo root."
exit 1
fi
cd "$root"
ensure_gh_api_auth
git fetch origin main
local dir=".worktrees/pr-$pr"
if [ -d "$dir" ]; then
if [ "$force_clean" = "true" ]; then
clean_pr_worktree_state "$root/$dir"
fi
cd "$dir"
git fetch origin main
if [ "$reset_to_main" = "true" ]; then
@@ -59,6 +68,30 @@ enter_worktree() {
mkdir -p .local
}
clean_pr_worktree_state() {
local worktree_dir="$1"
local root
root=$(repo_root)
case "$worktree_dir" in
"$root"/.worktrees/pr-*)
;;
*)
echo "Refusing to force-clean non-PR worktree path: $worktree_dir"
exit 1
;;
esac
[ -d "$worktree_dir" ] || return 0
git -C "$worktree_dir" rebase --abort >/dev/null 2>&1 || true
git -C "$worktree_dir" merge --abort >/dev/null 2>&1 || true
git -C "$worktree_dir" am --abort >/dev/null 2>&1 || true
git -C "$worktree_dir" cherry-pick --abort >/dev/null 2>&1 || true
git -C "$worktree_dir" reset --hard HEAD >/dev/null
git -C "$worktree_dir" clean -fd -e .local/ >/dev/null
}
pr_meta_json() {
local pr="$1"
gh pr view "$pr" --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,headRepositoryOwner,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup

View File

@@ -2,6 +2,11 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMMON_ROOT="$ROOT_DIR"
if common_git_dir=$(git -C "$ROOT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
COMMON_ROOT="$(cd "$(dirname "$common_git_dir")" && pwd)"
fi
if [[ $# -lt 1 ]]; then
echo "usage: run-node-tool.sh <tool> [args...]" >&2
@@ -11,6 +16,13 @@ fi
tool="$1"
shift
for candidate_root in "$ROOT_DIR" "$COMMON_ROOT"; do
candidate_bin="$candidate_root/node_modules/.bin/$tool"
if [[ -x "$candidate_bin" ]]; then
exec "$candidate_bin" "$@"
fi
done
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
exec pnpm exec "$tool" "$@"
fi

View File

@@ -21,7 +21,7 @@ const baseChangelog = `# Changelog
`;
describe("appendUnreleasedChangelogEntry", () => {
it("appends to the end of the requested unreleased section", () => {
it("falls back to appending when the new entry has no PR ref", () => {
const next = appendUnreleasedChangelogEntry(baseChangelog, {
section: "Fixes",
entry: "New fix entry.",
@@ -34,6 +34,165 @@ describe("appendUnreleasedChangelogEntry", () => {
expect(next).toContain("## 2026.4.5");
});
it("inserts a PR-linked entry ordered by PR number in the middle", () => {
const content = `# Changelog
## Unreleased
### Changes
- Earlier change (#100). Thanks @alice
- Later change (#300). Thanks @carol
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "Middle change (#200). Thanks @bob",
});
expect(next).toBe(`# Changelog
## Unreleased
### Changes
- Earlier change (#100). Thanks @alice
- Middle change (#200). Thanks @bob
- Later change (#300). Thanks @carol
## 2026.4.5
`);
});
it("inserts a PR-linked entry with the smallest number at the top of the section", () => {
const content = `# Changelog
## Unreleased
### Changes
- Later change (#300). Thanks @carol
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "Earliest change (#50). Thanks @alice",
});
expect(next).toBe(`# Changelog
## Unreleased
### Changes
- Earliest change (#50). Thanks @alice
- Later change (#300). Thanks @carol
## 2026.4.5
`);
});
it("inserts a PR-linked entry with the largest number at the tail of the section", () => {
const content = `# Changelog
## Unreleased
### Changes
- Earlier change (#100). Thanks @alice
- Later change (#200). Thanks @bob
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "Newest change (#500). Thanks @carol",
});
expect(next).toBe(`# Changelog
## Unreleased
### Changes
- Earlier change (#100). Thanks @alice
- Later change (#200). Thanks @bob
- Newest change (#500). Thanks @carol
## 2026.4.5
`);
});
it("inserts into an empty sub-section while preserving surrounding spacing", () => {
const content = `# Changelog
## Unreleased
### Changes
### Fixes
- Existing fix.
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "First change (#42). Thanks @alice",
});
expect(next).toContain("- First change (#42). Thanks @alice");
// 新条目落在空 Changes 里、位于 Fixes 之前
const changesIdx = next.indexOf("### Changes");
const firstIdx = next.indexOf("- First change");
const fixesIdx = next.indexOf("### Fixes");
expect(changesIdx).toBeLessThan(firstIdx);
expect(firstIdx).toBeLessThan(fixesIdx);
// Fixes 下原有条目未被打乱
expect(next).toContain(`### Fixes
- Existing fix.`);
});
it("skips historical bullets without PR refs when deciding order", () => {
const content = `# Changelog
## Unreleased
### Changes
- Legacy unlinked entry without a PR ref.
- Linked change (#300). Thanks @carol
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "Linked change (#150). Thanks @bob",
});
// 150 < 300新条目应该插在 (#300) 前面;没有 PR 号的历史行不当排序锚
expect(next).toBe(`# Changelog
## Unreleased
### Changes
- Legacy unlinked entry without a PR ref.
- Linked change (#150). Thanks @bob
- Linked change (#300). Thanks @carol
## 2026.4.5
`);
});
it("avoids duplicating an existing entry", () => {
const next = appendUnreleasedChangelogEntry(baseChangelog, {
section: "Changes",
@@ -43,6 +202,101 @@ describe("appendUnreleasedChangelogEntry", () => {
expect(next).toBe(baseChangelog);
});
it("avoids duplicating an equivalent entry with the same PR reference", () => {
const content = `# Changelog
## Unreleased
### Fixes
- Fix onboarding timeout handling (#123). Thanks @alice
## 2026.4.5
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Fixes",
entry: "Fix onboarding timeout handling openclaw#123. Thanks @alice",
});
expect(next).toBe(content);
});
it("blocks a merge-stage re-insert even when new text and section differ (PR #67679 regression)", () => {
// prepare 阶段:详细条目已经在 ### Fixes 里
const content = `# Changelog
## Unreleased
### Changes
- macOS/gateway: add screen.snapshot support. (#67954) Thanks @BunsDev.
### Fixes
- Config/redact: add \`browser.cdpUrl\` and \`browser.profiles.*.cdpUrl\` to sensitive URL config paths so embedded credentials are properly redacted. (#67679) Thanks @Ziy1-Tan.
## 2026.4.15
`;
// merge 阶段又走一次 ensure默认 section=ChangesPR title 作为短版本
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "fix: redact credentials in browser.cdpUrl config paths (#67679). Thanks @Ziy1-Tan",
});
// 同一 PR 号在 Unreleased 任意 subsection 已存在 → 不再插入
expect(next).toBe(content);
});
it("still inserts a new Unreleased entry when the same PR number exists only in a released block", () => {
// 老版本块里碰巧有同号,不应阻止 Unreleased 插入新条目
const content = `# Changelog
## Unreleased
### Changes
### Fixes
## 2026.4.15
### Fixes
- old released fix (#500). Thanks @alice
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "brand new change (#500). Thanks @alice",
});
expect(next).not.toBe(content);
expect(next).toContain("- brand new change (#500). Thanks @alice");
expect(next).toContain("- old released fix (#500). Thanks @alice");
});
it("does not treat #67 as a duplicate of #6767 (PR number prefix collision)", () => {
const content = `# Changelog
## Unreleased
### Changes
- longer PR (#6767). Thanks @alice
## 2026.4.15
`;
const next = appendUnreleasedChangelogEntry(content, {
section: "Changes",
entry: "shorter PR (#67). Thanks @bob",
});
expect(next).toContain("- longer PR (#6767)");
expect(next).toContain("- shorter PR (#67)");
});
it("throws when the unreleased section is missing", () => {
expect(() =>
appendUnreleasedChangelogEntry("# Changelog\n", {

View File

@@ -1,11 +1,60 @@
type UnreleasedSection = "Breaking" | "Changes" | "Fixes";
export type UnreleasedSection = "Breaking" | "Changes" | "Fixes";
function normalizePrRefToken(value: string): string {
const match = value.match(/(?:^|\()#(\d+)(?:\)|$)|openclaw#(\d+)/i);
const prNumber = match?.[1] ?? match?.[2];
return prNumber ? `#${prNumber}` : value.trim().toLowerCase();
}
function stripBullet(line: string): string {
return line.trim().replace(/^-\s+/, "");
}
function findPrReference(line: string): string | undefined {
const match = line.match(/(?:\(#\d+\)|openclaw#\d+)/i);
return match?.[0];
}
function entriesAreEquivalent(existingLine: string, newBullet: string): boolean {
const existingBody = stripBullet(existingLine);
const newBody = stripBullet(newBullet);
if (existingBody === newBody) {
return true;
}
const existingPrRef = findPrReference(existingBody);
const newPrRef = findPrReference(newBody);
if (!existingPrRef || !newPrRef) {
return false;
}
if (normalizePrRefToken(existingPrRef) !== normalizePrRefToken(newPrRef)) {
return false;
}
const existingWithoutRef = existingBody.replace(/(?:\s*\(#\d+\)|\s*openclaw#\d+)/gi, "").trim();
const newWithoutRef = newBody.replace(/(?:\s*\(#\d+\)|\s*openclaw#\d+)/gi, "").trim();
return existingWithoutRef === newWithoutRef;
}
function extractPrNumber(line: string): number | undefined {
// 只取行里第一个 PR 引用作为排序键,避免 "(#123, #456)" 取到 456
const match = line.match(/(?:\(#(\d+)\)|openclaw#(\d+))/i);
const raw = match?.[1] ?? match?.[2];
if (!raw) {
return undefined;
}
const num = Number.parseInt(raw, 10);
return Number.isFinite(num) ? num : undefined;
}
function findSectionRange(
lines: string[],
section: UnreleasedSection,
): {
start: number;
insertAt: number;
bodyStart: number;
bodyEnd: number;
} {
const unreleasedIndex = lines.findIndex((line) => line.trim() === "## Unreleased");
if (unreleasedIndex === -1) {
@@ -28,20 +77,81 @@ function findSectionRange(
throw new Error(`CHANGELOG.md is missing the '${sectionHeading}' section under Unreleased.`);
}
let insertAt = lines.length;
// bodyEnd 指向下一个 heading### 或 ##bodyStart 紧跟 section heading
let bodyEnd = lines.length;
for (let index = sectionIndex + 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.startsWith("### ") || line.startsWith("## ")) {
insertAt = index;
bodyEnd = index;
break;
}
}
while (insertAt > sectionIndex + 1 && lines[insertAt - 1]?.trim() === "") {
insertAt -= 1;
while (bodyEnd > sectionIndex + 1 && lines[bodyEnd - 1]?.trim() === "") {
bodyEnd -= 1;
}
return { start: sectionIndex, insertAt };
return { start: sectionIndex, bodyStart: sectionIndex + 1, bodyEnd };
}
function resolveOrderedInsertIndex(
lines: string[],
bodyStart: number,
bodyEnd: number,
newPr: number | undefined,
): number {
// 无 PR 号手写条目等fallback 到尾插,保持旧行为
if (newPr === undefined) {
return bodyEnd;
}
// 按 PR 号升序找第一个 PR 号大于 newPr 的已有条目,插到它前面
// 没有 PR 号的历史行(极少见)当成边界,原地跳过
for (let index = bodyStart; index < bodyEnd; index += 1) {
const line = lines[index];
if (!line.startsWith("- ")) {
continue;
}
const existingPr = extractPrNumber(line);
if (existingPr === undefined) {
continue;
}
if (existingPr > newPr) {
return index;
}
}
return bodyEnd;
}
function findUnreleasedRange(lines: string[]): { start: number; end: number } | undefined {
const unreleasedIndex = lines.findIndex((line) => line.trim() === "## Unreleased");
if (unreleasedIndex === -1) {
return undefined;
}
let end = lines.length;
for (let index = unreleasedIndex + 1; index < lines.length; index += 1) {
if (lines[index].startsWith("## ")) {
end = index;
break;
}
}
return { start: unreleasedIndex, end };
}
function unreleasedHasPrEntry(lines: string[], prNumber: number): boolean {
const range = findUnreleasedRange(lines);
if (!range) {
return false;
}
for (let index = range.start + 1; index < range.end; index += 1) {
const line = lines[index];
if (!line.startsWith("- ")) {
continue;
}
if (extractPrNumber(line) === prNumber) {
return true;
}
}
return false;
}
export function appendUnreleasedChangelogEntry(
@@ -58,11 +168,29 @@ export function appendUnreleasedChangelogEntry(
const lines = content.split("\n");
const bullet = entry.startsWith("- ") ? entry : `- ${entry}`;
if (lines.some((line) => line.trim() === bullet)) {
// 强去重:同 PR 号在 ## Unreleased 下任意 subsection 已存在 → 跳过
// 这避免了 merge 阶段二次 ensure 插入短版本,跟 prepare 阶段的详细条目重复
const newPr = extractPrNumber(bullet);
if (newPr !== undefined && unreleasedHasPrEntry(lines, newPr)) {
return content;
}
const { insertAt } = findSectionRange(lines, params.section);
lines.splice(insertAt, 0, bullet, "");
// 文本级去重兜底(无 PR 号的手写条目 / 同行完全相等)
if (lines.some((line) => entriesAreEquivalent(line, bullet))) {
return content;
}
const { bodyStart, bodyEnd } = findSectionRange(lines, params.section);
const insertAt = resolveOrderedInsertIndex(lines, bodyStart, bodyEnd, newPr);
// 空 section插到 heading 之后并补一个空行分隔
if (bodyEnd === bodyStart) {
lines.splice(insertAt, 0, bullet, "");
return lines.join("\n");
}
// 非空 section单独插一行复用已有前后空行
lines.splice(insertAt, 0, bullet);
return lines.join("\n");
}

View File

@@ -0,0 +1,51 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "../scripts/test-helpers.js";
const scriptPath = path.join(
process.cwd(),
".agents",
"skills",
"openclaw-secret-scanning-maintainer",
"scripts",
"secret-scanning.mjs",
);
const { createTempDir } = createScriptTestHarness();
function writeExecutable(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents, { encoding: "utf8", mode: 0o755 });
}
describe("secret-scanning skill script", () => {
it("supports a mock CLI smoke flow", () => {
const binDir = createTempDir("openclaw-secret-scan-bin-");
const fakeGhPath = path.join(binDir, "gh");
writeExecutable(
fakeGhPath,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\' \'{"id":321,"html_url":"https://github.com/openclaw/openclaw/issues/12#issuecomment-321"}\'',
].join("\n") + "\n",
);
const output = execFileSync(process.execPath, [scriptPath, "smoke"], {
encoding: "utf8",
env: {
...process.env,
OPENCLAW_SECRET_SCAN_GH_BIN: fakeGhPath,
},
stdio: ["ignore", "pipe", "pipe"],
});
expect(output).toContain('"ok":true');
expect(output).toContain("## Secret Scanning Results");
expect(output).toContain("comment redacted; author notified");
expect(output).toContain("Issues requiring GitHub Support to purge edit history:");
});
});

View File

@@ -0,0 +1,153 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const changelogScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "changelog.sh");
const { createTempDir } = createScriptTestHarness();
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
env: env ? { ...process.env, ...env } : process.env,
}).trim();
}
function git(cwd: string, ...args: string[]): string {
return run(cwd, "git", args);
}
function evaluateShell(cwd: string, body: string): string {
return run(
cwd,
"bash",
[
"-lc",
`
source "$OPENCLAW_PR_CHANGELOG_SH"
${body}
`,
],
{
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
},
);
}
function initRepo(prefix: string): string {
const repo = createTempDir(prefix);
git(repo, "init", "-q", "--initial-branch=main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test User");
writeFileSync(
path.join(repo, "CHANGELOG.md"),
"# Changelog\n\n## Unreleased\n\n### Changes\n\n",
"utf8",
);
git(repo, "add", "CHANGELOG.md");
git(repo, "commit", "-qm", "seed");
return repo;
}
describe("scripts/pr-lib/changelog.sh", () => {
it("prefers the previous prep head when it is an ancestor of HEAD", () => {
const repo = initRepo("openclaw-pr-lib-changelog-range-");
const baseSha = git(repo, "rev-parse", "HEAD");
git(repo, "update-ref", "refs/remotes/origin/main", baseSha);
git(repo, "checkout", "-qb", "feature");
writeFileSync(path.join(repo, "feature.txt"), "feature\n", "utf8");
git(repo, "add", "feature.txt");
git(repo, "commit", "-qm", "feature");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(path.join(repo, ".local", "prep.env"), `PR_HEAD_SHA_BEFORE=${baseSha}\n`, "utf8");
const diffRange = evaluateShell(repo, "resolve_changelog_diff_range");
expect(diffRange).toBe(`${baseSha}..HEAD`);
});
it("falls back to origin/main three-dot diff when prep metadata does not point to an ancestor", () => {
const repo = initRepo("openclaw-pr-lib-changelog-fallback-");
const seedSha = git(repo, "rev-parse", "HEAD");
git(repo, "checkout", "-qb", "feature");
writeFileSync(path.join(repo, "feature.txt"), "feature\n", "utf8");
git(repo, "add", "feature.txt");
git(repo, "commit", "-qm", "feature");
git(repo, "checkout", "main");
writeFileSync(path.join(repo, "main.txt"), "main\n", "utf8");
git(repo, "add", "main.txt");
git(repo, "commit", "-qm", "main advance");
const mainAdvanceSha = git(repo, "rev-parse", "HEAD");
git(repo, "update-ref", "refs/remotes/origin/main", mainAdvanceSha);
git(repo, "checkout", "feature");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(
path.join(repo, ".local", "prep.env"),
`PR_HEAD_SHA_BEFORE=${mainAdvanceSha}\nORIGINAL_SEED=${seedSha}\n`,
"utf8",
);
const diffRange = evaluateShell(repo, "resolve_changelog_diff_range");
expect(diffRange).toBe("origin/main...HEAD");
});
it("validates PR-linked changelog entries from the current file contents", () => {
const repo = createTempDir("openclaw-pr-lib-changelog-entry-");
writeFileSync(
path.join(repo, "CHANGELOG.md"),
[
"# Changelog",
"",
"## Unreleased",
"",
"### Fixes",
"",
"Fix bug in merge flow (#67082). Thanks @alice",
"",
].join("\n"),
"utf8",
);
const output = evaluateShell(repo, "validate_changelog_entry_for_pr 67082 alice");
expect(output).toContain("changelog placement validated");
expect(output).toContain("changelog validated: found PR #67082 + thanks @alice");
});
it("maps bug-fix labels to the Fixes section", () => {
const output = evaluateShell(
process.cwd(),
`printf '%s\\n' "$(resolve_pr_changelog_section '{"labels":[{"name":"bug"}]}')"`,
);
expect(output).toBe("Fixes");
});
it("lets an explicit override choose the changelog section", () => {
const output = run(
process.cwd(),
"bash",
[
"-lc",
`
source "$OPENCLAW_PR_CHANGELOG_SH"
printf '%s\\n' "$(resolve_pr_changelog_section '{"labels":[{"name":"bug"}]}')"
`,
],
{
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
OPENCLAW_PR_CHANGELOG_SECTION: "changes",
},
);
expect(output).toBe("Changes");
});
});

View File

@@ -0,0 +1,57 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
const commonScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "common.sh");
function evaluateChangelogRequired(files: string[]) {
const output = execFileSync(
"bash",
[
"-lc",
`
source "$OPENCLAW_PR_COMMON_SH"
if changelog_required_for_changed_files "$OPENCLAW_TEST_FILES"; then
printf true
else
printf false
fi
`,
],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_PR_COMMON_SH: commonScriptPath,
OPENCLAW_TEST_FILES: files.join("\n"),
},
},
).trim();
return output === "true";
}
describe("scripts/pr-lib/common.sh", () => {
it("does not require changelog entries for qa-only maintenance paths", () => {
expect(
evaluateChangelogRequired([
"extensions/qa-channel/src/bus-client.ts",
"extensions/qa-lab/src/bus-server.ts",
]),
).toBe(false);
});
it("does not require changelog entries for maintainer workflow paths", () => {
expect(evaluateChangelogRequired(["scripts/pr-lib/common.sh", "docs/subagent.md"])).toBe(false);
});
it("still requires changelog entries when qa-only paths are mixed with product code", () => {
expect(
evaluateChangelogRequired([
"extensions/qa-channel/src/bus-client.ts",
"src/gateway/server.ts",
]),
).toBe(true);
});
});

View File

@@ -0,0 +1,83 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const mergeScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "merge.sh");
const { createTempDir } = createScriptTestHarness();
function runMergeShell(body: string, env?: NodeJS.ProcessEnv) {
return spawnSync(
"bash",
[
"-lc",
`
source "$OPENCLAW_PR_MERGE_SH"
${body}
`,
],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_PR_MERGE_SH: mergeScriptPath,
...env,
},
},
);
}
describe("scripts/pr-lib/merge.sh", () => {
it("requires prepare-stage gates output before merge", () => {
const repo = createTempDir("openclaw-pr-lib-merge-");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(path.join(repo, ".local", "review.md"), "review\n", "utf8");
writeFileSync(path.join(repo, ".local", "review.json"), "{}\n", "utf8");
writeFileSync(path.join(repo, ".local", "prep.md"), "prep\n", "utf8");
writeFileSync(path.join(repo, ".local", "prep.env"), "PREP_HEAD_SHA=deadbeef\n", "utf8");
const result = runMergeShell(
`
enter_worktree() { cd "$OPENCLAW_TEST_REPO"; }
require_artifact() { [ -s "$1" ] || { echo "Missing required artifact: $1"; exit 1; }; }
merge_run 123
`,
{ OPENCLAW_TEST_REPO: repo },
);
expect(result.status).toBe(1);
expect(result.stdout).toContain("Missing required artifact: .local/gates.env");
});
it("prints captured changelog diagnostics to stderr on failure", () => {
const result = runMergeShell(`
ensure_pr_changelog_entry() {
printf 'first diagnostic\\nsecond diagnostic\\n'
return 1
}
run_merge_changelog_with_diagnostics 67082 contributor "PR title" Changes "Entry text"
`);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("first diagnostic");
expect(result.stderr).toContain("second diagnostic");
});
it("returns changelog output on success", () => {
const result = runMergeShell(`
ensure_pr_changelog_entry() {
printf 'pr_changelog_changed=true\\n'
}
run_merge_changelog_with_diagnostics 67082 contributor "PR title" Changes "Entry text"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("pr_changelog_changed=true");
expect(result.stderr).toBe("");
});
});

View File

@@ -0,0 +1,235 @@
import { execFileSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const prepareCoreScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "prepare-core.sh");
const worktreeScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "worktree.sh");
const { createTempDir } = createScriptTestHarness();
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
env: env ? { ...process.env, ...env } : process.env,
}).trim();
}
function git(cwd: string, ...args: string[]): string {
return run(cwd, "git", args);
}
function runPrepareCoreShell(cwd: string, body: string): string {
return run(
cwd,
"bash",
[
"-lc",
`
source "$OPENCLAW_PREPARE_CORE_SH"
${body}
`,
],
{
OPENCLAW_PREPARE_CORE_SH: prepareCoreScriptPath,
},
);
}
function runWorktreeShell(cwd: string, body: string, env?: NodeJS.ProcessEnv): string {
return run(
cwd,
"bash",
[
"-lc",
`
source "$OPENCLAW_WORKTREE_SH"
${body}
`,
],
{
OPENCLAW_WORKTREE_SH: worktreeScriptPath,
...env,
},
);
}
describe("scripts/pr-lib/prepare-core.sh", () => {
it("resets PREP_REBASE_COUNT during prepare-init", () => {
const repo = createTempDir("openclaw-pr-lib-prepare-init-");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(path.join(repo, ".local", "review.md"), "# review\n", "utf8");
writeFileSync(path.join(repo, ".local", "review.json"), "{}\n", "utf8");
writeFileSync(path.join(repo, ".local", "pr-meta.env"), "PR_HEAD=feature\n", "utf8");
writeFileSync(
path.join(repo, ".local", "prep-context.env"),
"PREP_REBASE_COUNT=2\nPREP_BRANCH=pr-123-prep\n",
"utf8",
);
runPrepareCoreShell(
repo,
`
enter_worktree() { :; }
require_artifact() { [ -e "$1" ] || exit 1; }
pr_meta_json() { printf '%s\\n' '{"headRefName":"feature","headRefOid":"deadbeef"}'; }
git() {
case "$1" in
fetch|checkout)
return 0
;;
branch)
if [ "$2" = "--show-current" ]; then
printf 'pr-123-prep\\n'
return 0
fi
;;
esac
echo "unexpected git invocation: $*" >&2
exit 1
}
prepare_init 123 false
`,
);
const prepContext = readFileSync(path.join(repo, ".local", "prep-context.env"), "utf8");
expect(prepContext).toContain("PREP_REBASE_COUNT=0");
});
it("allows an additional sync rebase only when --force is used", () => {
expect(
runPrepareCoreShell(
process.cwd(),
'if prepare_sync_rebase_allowed 1 false; then printf "allowed"; else printf "blocked"; fi',
),
).toBe("blocked");
expect(
runPrepareCoreShell(
process.cwd(),
'if prepare_sync_rebase_allowed 1 true; then printf "allowed"; else printf "blocked"; fi',
),
).toBe("allowed");
});
it("adds and commits a required changelog entry during prepare-push", () => {
const repo = createTempDir("openclaw-pr-lib-prepare-push-");
mkdirSync(path.join(repo, ".local"), { recursive: true });
mkdirSync(path.join(repo, "scripts"), { recursive: true });
writeFileSync(
path.join(repo, ".local", "pr-meta.env"),
"PR_HEAD=feature\nPR_AUTHOR=alice\nPR_URL=https://example.test/pr/123\nPR_NUMBER=123\n",
"utf8",
);
writeFileSync(
path.join(repo, ".local", "prep-context.env"),
"PREP_BRANCH=pr-123-prep\n",
"utf8",
);
writeFileSync(
path.join(repo, ".local", "gates.env"),
"CHANGELOG_REQUIRED=true\nDOCS_ONLY=false\nGATES_MODE=changed\nBUILD_GATE_STATUS=passed\nCHECK_GATE_STATUS=passed\nTEST_GATE_STATUS=passed\n",
"utf8",
);
writeFileSync(path.join(repo, ".local", "prep.md"), "# prep\n", "utf8");
writeFileSync(
path.join(repo, "scripts", "committer"),
"#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > .local/committer.log\n",
"utf8",
);
chmodSync(path.join(repo, "scripts", "committer"), 0o755);
runPrepareCoreShell(
repo,
`
enter_worktree() { :; }
require_artifact() { [ -s "$1" ] || { echo "missing $1" >&2; exit 1; }; }
checkout_prep_branch() { :; }
verify_pr_head_branch_matches_expected() { :; }
resolve_pr_changelog_entry() { printf '%s\\n' 'Config: accept truncateAfterCompaction (#123). Thanks @alice'; }
resolve_pr_changelog_section() { printf 'Fixes\\n'; }
ensure_pr_changelog_entry() { printf 'Updated CHANGELOG.md (Fixes).\\npr_changelog_changed=true\\n'; }
pr_meta_json() { printf '%s\\n' '{"title":"Config: accept truncateAfterCompaction","author":{"login":"alice"},"labels":[{"name":"bug"}]}'; }
push_prep_head_to_pr_branch() {
cat > "$7" <<'EOF_PUSH'
PUSH_PREP_HEAD_SHA=prep-after
PUSHED_FROM_SHA=remote-before
PR_HEAD_SHA_AFTER_PUSH=prep-after
PUSH_MAIN_STATUS=up_to_date
EOF_PUSH
}
gh() {
if [ "$1" = "api" ] && [ "$2" = "users/alice" ] && [ "$3" = "--jq" ] && [ "$4" = ".id" ]; then
printf '42\\n'
return 0
fi
if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "123" ] && [ "$4" = "--json" ] && [ "$5" = "headRefOid" ] && [ "$6" = "--jq" ] && [ "$7" = ".headRefOid" ]; then
printf 'prep-after\\n'
return 0
fi
echo "unexpected gh invocation: $*" >&2
exit 1
}
git() {
case "$1" in
rev-parse)
if [ "$2" = "HEAD" ]; then
printf 'prep-after\\n'
return 0
fi
;;
branch)
if [ "$2" = "--show-current" ]; then
printf 'pr-123-prep\\n'
return 0
fi
;;
esac
return 0
}
prepare_push 123
`,
);
const commitLog = readFileSync(path.join(repo, ".local", "committer.log"), "utf8");
expect(commitLog).toContain("--fast");
expect(commitLog).toContain("Config: accept truncateAfterCompaction");
expect(commitLog).toContain("CHANGELOG.md");
const prepLog = readFileSync(path.join(repo, ".local", "prep.md"), "utf8");
expect(prepLog).toContain("Prepare-stage changelog status: added_and_committed.");
});
});
describe("scripts/pr-lib/worktree.sh", () => {
it("force-cleans only the targeted PR worktree", () => {
const root = createTempDir("openclaw-pr-lib-worktree-root-");
const worktreeDir = path.join(root, ".worktrees", "pr-123");
mkdirSync(worktreeDir, { recursive: true });
git(worktreeDir, "init", "-q", "--initial-branch=main");
git(worktreeDir, "config", "user.email", "test@example.com");
git(worktreeDir, "config", "user.name", "Test User");
writeFileSync(path.join(worktreeDir, "tracked.txt"), "seed\n", "utf8");
git(worktreeDir, "add", "tracked.txt");
git(worktreeDir, "commit", "-qm", "seed");
writeFileSync(path.join(worktreeDir, "tracked.txt"), "dirty\n", "utf8");
writeFileSync(path.join(worktreeDir, "untracked.txt"), "remove me\n", "utf8");
mkdirSync(path.join(worktreeDir, ".local"), { recursive: true });
writeFileSync(path.join(worktreeDir, ".local", "pr-meta.env"), "KEEP=1\n", "utf8");
runWorktreeShell(
root,
`
repo_root() { printf '%s\\n' "$TEST_REPO_ROOT"; }
clean_pr_worktree_state "$TEST_REPO_ROOT/.worktrees/pr-123"
`,
{ TEST_REPO_ROOT: root },
);
expect(readFileSync(path.join(worktreeDir, "tracked.txt"), "utf8")).toBe("seed\n");
expect(existsSync(path.join(worktreeDir, "untracked.txt"))).toBe(false);
expect(readFileSync(path.join(worktreeDir, ".local", "pr-meta.env"), "utf8")).toBe("KEEP=1\n");
});
});

View File

@@ -0,0 +1,83 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const pushScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "push.sh");
const worktreeScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "worktree.sh");
const { createTempDir } = createScriptTestHarness();
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
env: env ? { ...process.env, ...env } : process.env,
}).trim();
}
function git(cwd: string, ...args: string[]): string {
return run(cwd, "git", args);
}
function runPushShell(cwd: string, body: string): string {
return run(
cwd,
"bash",
[
"-lc",
`
source "$OPENCLAW_WORKTREE_SH"
source "$OPENCLAW_PUSH_SH"
${body}
`,
],
{
OPENCLAW_PUSH_SH: pushScriptPath,
OPENCLAW_WORKTREE_SH: worktreeScriptPath,
},
);
}
describe("scripts/pr-lib/push.sh", () => {
it("refreshes PR head metadata before configuring prhead", () => {
const repo = createTempDir("openclaw-pr-lib-push-");
git(repo, "init", "-q", "--initial-branch=main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test User");
writeFileSync(path.join(repo, "tracked.txt"), "seed\n", "utf8");
git(repo, "add", "tracked.txt");
git(repo, "commit", "-qm", "seed");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(
path.join(repo, ".local", "pr-meta.env"),
[
"PR_HEAD_OWNER=stale-owner",
"PR_HEAD_REPO_NAME=stale-repo",
"PR_HEAD_REPO_URL=https://github.com/stale-owner/stale-repo",
"",
].join("\n"),
"utf8",
);
runPushShell(
repo,
`
pr_meta_json() {
printf '%s\\n' '{"number":123,"url":"https://github.com/openclaw/openclaw/pull/123","author":{"login":"alice"},"baseRefName":"main","headRefName":"feature","headRefOid":"deadbeef","headRepository":{"nameWithOwner":"fresh-owner/fresh-repo","url":"https://github.com/fresh-owner/fresh-repo","name":"fresh-repo"},"headRepositoryOwner":{"login":"fresh-owner"}}'
}
setup_prhead_remote 123
git remote get-url prhead
`,
);
const prMetaEnv = readFileSync(path.join(repo, ".local", "pr-meta.env"), "utf8");
expect(prMetaEnv).toContain("PR_HEAD_OWNER=fresh-owner");
expect(prMetaEnv).toContain("PR_HEAD_REPO_NAME=fresh-repo");
expect(git(repo, "remote", "get-url", "prhead")).toBe(
"https://github.com/fresh-owner/fresh-repo.git",
);
});
});

View File

@@ -0,0 +1,139 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const reviewScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "review.sh");
const { createTempDir } = createScriptTestHarness();
function runReviewShell(cwd: string, body: string): string {
return execFileSync(
"bash",
[
"-lc",
`
source "$OPENCLAW_PR_REVIEW_SH"
${body}
`,
],
{
cwd,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_PR_REVIEW_SH: reviewScriptPath,
},
},
).trim();
}
describe("scripts/pr-lib/review.sh", () => {
it("accepts review.json artifacts without a changelog decision", () => {
const repo = createTempDir("openclaw-pr-lib-review-");
mkdirSync(path.join(repo, ".local"), { recursive: true });
writeFileSync(
path.join(repo, ".local", "review.md"),
[
"A) TL;DR recommendation",
"",
"NEEDS WORK",
"",
"B) What changed and what is good?",
"",
"Pending review.",
"",
"C) Security findings",
"",
"None yet.",
"",
"D) What is the PR intent? Is this the most optimal implementation?",
"",
"Pending review.",
"",
"E) Concerns or questions (actionable)",
"",
"None yet.",
"",
"F) Tests",
"",
"Not run.",
"",
"G) Docs status",
"",
"not_applicable",
"",
"H) Prepare-stage changelog handoff",
"",
"Prepare owns the authoritative changelog-required decision.",
"",
"I) Follow ups (optional)",
"",
"None.",
"",
"J) Suggested PR comment (optional)",
"",
"Pending review.",
"",
].join("\n"),
"utf8",
);
writeFileSync(
path.join(repo, ".local", "review.json"),
JSON.stringify(
{
recommendation: "NEEDS WORK",
findings: [],
nitSweep: {
performed: true,
status: "none",
summary: "No optional nits identified.",
},
behavioralSweep: {
performed: true,
status: "not_applicable",
summary: "No runtime branch-level behavior changes require sweep evidence.",
silentDropRisk: "none",
branches: [],
},
issueValidation: {
performed: true,
source: "pr_body",
status: "unclear",
summary: "Review not completed yet.",
},
tests: {
ran: [],
gaps: [],
result: "pass",
},
docs: "not_applicable",
},
null,
2,
),
"utf8",
);
writeFileSync(path.join(repo, ".local", "pr-meta.env"), "PR_HEAD_SHA=dummy\n", "utf8");
writeFileSync(
path.join(repo, ".local", "pr-meta.json"),
JSON.stringify({ files: [{ path: "docs/help.md" }] }, null, 2),
"utf8",
);
const output = runReviewShell(
repo,
`
enter_worktree() { :; }
require_artifact() { [ -s "$1" ] || { echo "Missing required artifact: $1"; exit 1; }; }
review_guard() { :; }
print_review_stdout_summary() { echo "summary"; }
review_validate_artifacts 123
`,
);
expect(output).toContain("review artifacts validated");
expect(output).toContain("summary");
});
});

View File

@@ -0,0 +1,59 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createScriptTestHarness } from "./test-helpers.js";
const scriptSourcePath = path.join(process.cwd(), "scripts", "pre-commit", "run-node-tool.sh");
const { createTempDir } = createScriptTestHarness();
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
return execFileSync(command, args, {
cwd,
encoding: "utf8",
env: env ? { ...process.env, ...env } : process.env,
}).trim();
}
function git(cwd: string, ...args: string[]): string {
return run(cwd, "git", args);
}
function writeExecutable(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents, { encoding: "utf8", mode: 0o755 });
}
describe("scripts/pre-commit/run-node-tool.sh", () => {
it("reuses the common-root node_modules tool from a linked worktree", () => {
const repo = createTempDir("openclaw-run-node-tool-repo-");
const worktree = createTempDir("openclaw-run-node-tool-worktree-");
git(repo, "init", "-q", "--initial-branch=main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test User");
writeExecutable(
path.join(repo, "scripts", "pre-commit", "run-node-tool.sh"),
readFileSync(scriptSourcePath, "utf8"),
);
writeFileSync(path.join(repo, "tracked.txt"), "seed\n", "utf8");
git(repo, "add", "scripts/pre-commit/run-node-tool.sh", "tracked.txt");
git(repo, "commit", "-qm", "seed");
writeExecutable(
path.join(repo, "node_modules", ".bin", "oxlint"),
'#!/usr/bin/env bash\nprintf "shared-root-oxlint %s\\n" "$*"\n',
);
git(repo, "worktree", "add", "-b", "wt", worktree, "HEAD");
const output = run(worktree, "bash", [
"scripts/pre-commit/run-node-tool.sh",
"oxlint",
"--version",
]);
expect(output).toBe("shared-root-oxlint --version");
});
});