mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
18 Commits
v2026.5.3
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ac4f3e2c | ||
|
|
122ba937ec | ||
|
|
7933d04ac8 | ||
|
|
bdb94ab096 | ||
|
|
281e899f4b | ||
|
|
4f53718a36 | ||
|
|
85b9fd25cf | ||
|
|
483ab6a879 | ||
|
|
be910c78bd | ||
|
|
9b677116c8 | ||
|
|
196b04e0a6 | ||
|
|
7c146da949 | ||
|
|
81cd0424a5 | ||
|
|
f475e4192a | ||
|
|
ac942d925c | ||
|
|
1fb81d1275 | ||
|
|
f5722e2a69 | ||
|
|
a1cdddd5dc |
@@ -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);
|
||||
|
||||
@@ -12,6 +12,9 @@ paths-ignore:
|
||||
- docs
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*-runtime.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
|
||||
@@ -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 + what’s 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.
|
||||
|
||||
49
scripts/pr
49
scripts/pr
@@ -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-}"
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=Changes,PR 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", {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
51
test/agents/secret-scanning-script.test.ts
Normal file
51
test/agents/secret-scanning-script.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
153
test/scripts/pr-lib-changelog.test.ts
Normal file
153
test/scripts/pr-lib-changelog.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
57
test/scripts/pr-lib-common.test.ts
Normal file
57
test/scripts/pr-lib-common.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
test/scripts/pr-lib-merge.test.ts
Normal file
83
test/scripts/pr-lib-merge.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
235
test/scripts/pr-lib-prepare-core.test.ts
Normal file
235
test/scripts/pr-lib-prepare-core.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
83
test/scripts/pr-lib-push.test.ts
Normal file
83
test/scripts/pr-lib-push.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
139
test/scripts/pr-lib-review.test.ts
Normal file
139
test/scripts/pr-lib-review.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
59
test/scripts/run-node-tool.test.ts
Normal file
59
test/scripts/run-node-tool.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user