Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
402
scripts/pr-lib/changelog.sh
Normal file
402
scripts/pr-lib/changelog.sh
Normal file
@@ -0,0 +1,402 @@
|
||||
changelog_helper_root() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
|
||||
}
|
||||
|
||||
changelog_attribution_script() {
|
||||
printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs"
|
||||
}
|
||||
|
||||
normalize_pr_changelog_entries() {
|
||||
local pr="$1"
|
||||
local changelog_path="CHANGELOG.md"
|
||||
|
||||
[ -f "$changelog_path" ] || return 0
|
||||
|
||||
PR_NUMBER_FOR_CHANGELOG="$pr" node <<'EOF_NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const pr = process.env.PR_NUMBER_FOR_CHANGELOG;
|
||||
const path = "CHANGELOG.md";
|
||||
const original = fs.readFileSync(path, "utf8");
|
||||
const lines = original.split("\n");
|
||||
const prPattern = new RegExp(`(?:\\(#${pr}\\)|openclaw#${pr})`, "i");
|
||||
|
||||
function findActiveSectionIndex(arr) {
|
||||
const versionUnreleasedIndex = arr.findIndex((line) =>
|
||||
/^##\s+.+\(\s*unreleased\s*\)\s*$/i.test(line.trim()),
|
||||
);
|
||||
if (versionUnreleasedIndex !== -1) {
|
||||
return versionUnreleasedIndex;
|
||||
}
|
||||
return arr.findIndex((line) => line.trim().toLowerCase() === "## unreleased");
|
||||
}
|
||||
|
||||
function findSectionEnd(arr, start) {
|
||||
for (let i = start + 1; i < arr.length; i += 1) {
|
||||
if (/^## /.test(arr[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return arr.length;
|
||||
}
|
||||
|
||||
function ensureActiveSection(arr) {
|
||||
let activeIndex = findActiveSectionIndex(arr);
|
||||
if (activeIndex !== -1) {
|
||||
return activeIndex;
|
||||
}
|
||||
|
||||
let insertAt = arr.findIndex((line, idx) => idx > 0 && /^## /.test(line));
|
||||
if (insertAt === -1) {
|
||||
insertAt = arr.length;
|
||||
}
|
||||
|
||||
const block = ["## Unreleased", "", "### Changes", ""];
|
||||
if (insertAt > 0 && arr[insertAt - 1] !== "") {
|
||||
block.unshift("");
|
||||
}
|
||||
arr.splice(insertAt, 0, ...block);
|
||||
return findActiveSectionIndex(arr);
|
||||
}
|
||||
|
||||
function contextFor(arr, index) {
|
||||
let major = "";
|
||||
let minor = "";
|
||||
for (let i = index; i >= 0; i -= 1) {
|
||||
const line = arr[i];
|
||||
if (!minor && /^### /.test(line)) {
|
||||
minor = line.trim();
|
||||
}
|
||||
if (/^## /.test(line)) {
|
||||
major = line.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
function ensureSubsection(arr, subsection) {
|
||||
const activeIndex = ensureActiveSection(arr);
|
||||
const activeEnd = findSectionEnd(arr, activeIndex);
|
||||
const desired = subsection && /^### /.test(subsection) ? subsection : "### Changes";
|
||||
for (let i = activeIndex + 1; i < activeEnd; i += 1) {
|
||||
if (arr[i].trim() === desired) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = activeEnd;
|
||||
while (insertAt > activeIndex + 1 && arr[insertAt - 1] === "") {
|
||||
insertAt -= 1;
|
||||
}
|
||||
const block = ["", desired, ""];
|
||||
arr.splice(insertAt, 0, ...block);
|
||||
return insertAt + 1;
|
||||
}
|
||||
|
||||
function sectionTailInsertIndex(arr, subsectionIndex) {
|
||||
let nextHeading = arr.length;
|
||||
for (let i = subsectionIndex + 1; i < arr.length; i += 1) {
|
||||
if (/^### /.test(arr[i]) || /^## /.test(arr[i])) {
|
||||
nextHeading = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = nextHeading;
|
||||
while (insertAt > subsectionIndex + 1 && arr[insertAt - 1] === "") {
|
||||
insertAt -= 1;
|
||||
}
|
||||
return insertAt;
|
||||
}
|
||||
|
||||
const activeHeading = lines[ensureActiveSection(lines)]?.trim() || "## Unreleased";
|
||||
|
||||
const moved = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (!prPattern.test(lines[i])) {
|
||||
continue;
|
||||
}
|
||||
const ctx = contextFor(lines, i);
|
||||
if (ctx.major === activeHeading) {
|
||||
continue;
|
||||
}
|
||||
moved.push({
|
||||
line: lines[i],
|
||||
subsection: ctx.minor || "### Changes",
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
|
||||
if (moved.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const removeIndexes = new Set(moved.map((entry) => entry.index));
|
||||
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) {
|
||||
if (/^### /.test(nextLines[i]) || /^## /.test(nextLines[i])) {
|
||||
nextHeading = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyPresent = nextLines
|
||||
.slice(subsectionIndex + 1, nextHeading)
|
||||
.some((line) => line === entry.line);
|
||||
if (alreadyPresent) {
|
||||
continue;
|
||||
}
|
||||
nextLines.splice(insertAt, 0, entry.line);
|
||||
}
|
||||
|
||||
const updated = nextLines.join("\n");
|
||||
if (updated !== original) {
|
||||
fs.writeFileSync(path, updated);
|
||||
}
|
||||
EOF_NODE
|
||||
}
|
||||
|
||||
validate_changelog_attribution_policy() {
|
||||
node "$(changelog_attribution_script)" CHANGELOG.md
|
||||
}
|
||||
|
||||
changelog_thanks_required_for_contributor() {
|
||||
local contrib="${1:-}"
|
||||
[ -n "$contrib" ] || return 1
|
||||
node "$(changelog_attribution_script)" --is-forbidden-handle "$contrib" && return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
changelog_explicit_human_thanks_required_for_contributor() {
|
||||
local contrib="${1:-}"
|
||||
[ -n "$contrib" ] || return 1
|
||||
node "$(changelog_attribution_script)" --requires-explicit-human-thanks "$contrib"
|
||||
}
|
||||
|
||||
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" | grep -Ein "$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" '
|
||||
BEGIN {
|
||||
line_no = 0
|
||||
file_line_count = 0
|
||||
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
|
||||
}
|
||||
{
|
||||
changelog[FNR] = $0
|
||||
file_line_count = FNR
|
||||
}
|
||||
END {
|
||||
active_release_line = 0
|
||||
bare_release_line = 0
|
||||
active_release_name = "unreleased"
|
||||
for (i = 1; i <= file_line_count; i++) {
|
||||
if (changelog[i] !~ /^## /) {
|
||||
continue
|
||||
}
|
||||
heading = tolower(changelog[i])
|
||||
if (heading ~ /^##[[:space:]]+.+\([[:space:]]*unreleased[[:space:]]*\)[[:space:]]*$/) {
|
||||
active_release_line = i
|
||||
active_release_name = changelog[i]
|
||||
break
|
||||
}
|
||||
if (heading == "## unreleased" && bare_release_line == 0) {
|
||||
bare_release_line = i
|
||||
}
|
||||
}
|
||||
if (active_release_line == 0 && bare_release_line != 0) {
|
||||
active_release_line = bare_release_line
|
||||
active_release_name = changelog[bare_release_line]
|
||||
}
|
||||
|
||||
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 || release_line != active_release_line) {
|
||||
printf "CHANGELOG.md PR-linked entry must be in %s: line %d: %s\n", active_release_name, 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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
' "$diff_file" CHANGELOG.md; then
|
||||
rm -f "$diff_file"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$diff_file"
|
||||
echo "changelog placement validated: PR-linked entries are appended at section tail"
|
||||
|
||||
if changelog_thanks_required_for_contributor "$contrib"; then
|
||||
local with_pr_and_thanks
|
||||
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Fi "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
|
||||
fi
|
||||
echo "changelog validated: found PR #$pr + thanks @$contrib"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! changelog_explicit_human_thanks_required_for_contributor "$contrib"; then
|
||||
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local with_pr_and_any_thanks
|
||||
with_pr_and_any_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Ei '(^|[[:space:]])thanks[[:space:]]+@' || true)
|
||||
if [ -z "$with_pr_and_any_thanks" ]; then
|
||||
echo "CHANGELOG.md update for bot/app/non-creditable author $contrib must include an explicit human Thanks @handle on the PR #$pr entry line."
|
||||
echo "Choose the credited original contributor, or stop for maintainer input if authorship is unclear."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "changelog validated: found PR #$pr + explicit thanks for bot/app/non-creditable author $contrib"
|
||||
}
|
||||
|
||||
validate_changelog_merge_hygiene() {
|
||||
local diff
|
||||
diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md)
|
||||
|
||||
local removed_lines
|
||||
removed_lines=$(printf '%s\n' "$diff" | awk '
|
||||
/^---/ { next }
|
||||
/^-/ { print substr($0, 2) }
|
||||
')
|
||||
if [ -z "$removed_lines" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local removed_refs
|
||||
removed_refs=$(printf '%s\n' "$removed_lines" | grep -Eo '#[0-9]+' | sort -u || true)
|
||||
if [ -z "$removed_refs" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local added_lines
|
||||
added_lines=$(printf '%s\n' "$diff" | awk '
|
||||
/^\+\+\+/ { next }
|
||||
/^\+/ { print substr($0, 2) }
|
||||
')
|
||||
|
||||
local ref
|
||||
while IFS= read -r ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
if ! printf '%s\n' "$added_lines" | grep -Fq "$ref"; then
|
||||
echo "CHANGELOG.md drops existing entry reference $ref without re-adding it."
|
||||
echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)."
|
||||
exit 1
|
||||
fi
|
||||
done <<<"$removed_refs"
|
||||
|
||||
echo "changelog merge hygiene validated: no dropped PR references"
|
||||
}
|
||||
311
scripts/pr-lib/common.sh
Normal file
311
scripts/pr-lib/common.sh
Normal file
@@ -0,0 +1,311 @@
|
||||
require_artifact() {
|
||||
local path="$1"
|
||||
if [ ! -s "$path" ]; then
|
||||
echo "Missing required artifact: $path"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
path_is_docsish() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
CHANGELOG.md|AGENTS.md|CLAUDE.md|README*.md|docs/*|*.md|*.mdx|mintlify.json|docs.json)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
file_list_is_docsish_only() {
|
||||
local files="$1"
|
||||
local saw_any=false
|
||||
local path
|
||||
while IFS= read -r path; do
|
||||
[ -n "$path" ] || continue
|
||||
saw_any=true
|
||||
if ! path_is_docsish "$path"; then
|
||||
return 1
|
||||
fi
|
||||
done <<<"$files"
|
||||
|
||||
[ "$saw_any" = "true" ]
|
||||
}
|
||||
|
||||
changelog_required_for_changed_files() {
|
||||
# CHANGELOG.md is release-owned. Normal PRs carry release-note context in
|
||||
# PR bodies and commit messages; release automation generates the file.
|
||||
return 1
|
||||
}
|
||||
|
||||
print_review_stdout_summary() {
|
||||
require_artifact .local/review.md
|
||||
require_artifact .local/review.json
|
||||
require_artifact .local/pr-meta.env
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
local recommendation
|
||||
recommendation=$(jq -r '.recommendation // ""' .local/review.json)
|
||||
local finding_count
|
||||
finding_count=$(jq '[.findings[]?] | length' .local/review.json)
|
||||
|
||||
echo "review summary:"
|
||||
echo "pr_url=${PR_URL:-}"
|
||||
echo "recommendation: $recommendation"
|
||||
echo "findings: $finding_count"
|
||||
cat .local/review.md
|
||||
}
|
||||
|
||||
print_relevant_log_excerpt() {
|
||||
local log_file="$1"
|
||||
if [ ! -s "$log_file" ]; then
|
||||
echo "(no output captured)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local filtered_log
|
||||
filtered_log=$(mktemp)
|
||||
if rg -n -i 'error|err|failed|fail|fatal|panic|exception|TypeError|ReferenceError|SyntaxError|ELIFECYCLE|ERR_' "$log_file" >"$filtered_log"; then
|
||||
echo "Relevant log lines:"
|
||||
tail -n 120 "$filtered_log"
|
||||
else
|
||||
echo "No focused error markers found; showing last 120 lines:"
|
||||
tail -n 120 "$log_file"
|
||||
fi
|
||||
rm -f "$filtered_log"
|
||||
}
|
||||
|
||||
print_unrelated_gate_failure_guidance() {
|
||||
local label="$1"
|
||||
case "$label" in
|
||||
pnpm\ build*|pnpm\ check*|pnpm\ test*)
|
||||
cat <<'EOF_GUIDANCE'
|
||||
If this local gate failure already reproduces on latest origin/main and is clearly unrelated to the PR:
|
||||
- treat it as baseline repo noise
|
||||
- document it explicitly
|
||||
- report the scoped verification that validates the PR itself
|
||||
- do not use this to ignore plausibly related failures
|
||||
EOF_GUIDANCE
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_quiet_logged() {
|
||||
local label="$1"
|
||||
local log_file="$2"
|
||||
shift 2
|
||||
|
||||
mkdir -p .local
|
||||
if "$@" >"$log_file" 2>&1; then
|
||||
echo "$label passed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$label failed (log: $log_file)"
|
||||
print_relevant_log_excerpt "$log_file"
|
||||
print_unrelated_gate_failure_guidance "$label"
|
||||
return 1
|
||||
}
|
||||
|
||||
bootstrap_deps_if_needed() {
|
||||
if [ ! -x node_modules/.bin/vitest ]; then
|
||||
run_quiet_logged "pnpm install --frozen-lockfile" ".local/bootstrap-install.log" pnpm install --frozen-lockfile
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_pr_head_sha() {
|
||||
local pr="$1"
|
||||
local expected_sha="$2"
|
||||
local max_attempts="${3:-6}"
|
||||
local sleep_seconds="${4:-2}"
|
||||
|
||||
local attempt
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
local observed_sha
|
||||
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
if [ "$observed_sha" = "$expected_sha" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
sleep "$sleep_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
pr_contributor_allows_human_trailers() {
|
||||
local contrib="${1:-}"
|
||||
local normalized
|
||||
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
case "$normalized" in
|
||||
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_contributor_coauthor_email() {
|
||||
local contrib="${1:-}"
|
||||
|
||||
if ! pr_contributor_allows_human_trailers "$contrib"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local contrib_id
|
||||
contrib_id=$(gh api "users/$contrib" --jq .id) || return 1
|
||||
printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib"
|
||||
}
|
||||
|
||||
common_repo_root() {
|
||||
if command -v repo_root >/dev/null 2>&1; then
|
||||
repo_root
|
||||
return
|
||||
fi
|
||||
|
||||
local base_dir
|
||||
base_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
git -C "$base_dir" rev-parse --show-toplevel
|
||||
}
|
||||
|
||||
worktree_path_for_branch() {
|
||||
local branch="$1"
|
||||
local ref="refs/heads/$branch"
|
||||
|
||||
git worktree list --porcelain | awk -v ref="$ref" '
|
||||
/^worktree / {
|
||||
worktree=$2
|
||||
next
|
||||
}
|
||||
/^branch / {
|
||||
if ($2 == ref) {
|
||||
print worktree
|
||||
found=1
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (!found) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
worktree_is_registered() {
|
||||
local path="$1"
|
||||
git worktree list --porcelain | awk -v target="$path" '
|
||||
/^worktree / {
|
||||
if ($2 == target) {
|
||||
found=1
|
||||
}
|
||||
}
|
||||
END {
|
||||
exit found ? 0 : 1
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
resolve_existing_dir_path() {
|
||||
local path="$1"
|
||||
if [ ! -d "$path" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$path" >/dev/null 2>&1 &&
|
||||
pwd -P
|
||||
)
|
||||
}
|
||||
|
||||
is_repo_pr_worktree_dir() {
|
||||
local path="$1"
|
||||
local root
|
||||
root=$(common_repo_root)
|
||||
|
||||
local worktrees_dir="$root/.worktrees"
|
||||
local resolved_path
|
||||
resolved_path=$(resolve_existing_dir_path "$path" 2>/dev/null || true)
|
||||
if [ -z "$resolved_path" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local resolved_worktrees_dir
|
||||
resolved_worktrees_dir=$(resolve_existing_dir_path "$worktrees_dir" 2>/dev/null || true)
|
||||
if [ -z "$resolved_worktrees_dir" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$resolved_path" in
|
||||
"$resolved_worktrees_dir"/pr-*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
remove_worktree_if_present() {
|
||||
local path="$1"
|
||||
if [ ! -e "$path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if worktree_is_registered "$path"; then
|
||||
git worktree remove "$path" --force >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ ! -e "$path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if worktree_is_registered "$path"; then
|
||||
echo "Warning: failed to remove registered worktree $path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! is_repo_pr_worktree_dir "$path"; then
|
||||
echo "Warning: refusing to trash non-PR-worktree path $path"
|
||||
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
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Warning: orphaned worktree dir remains and trash is unavailable: $path"
|
||||
return 0
|
||||
}
|
||||
|
||||
delete_local_branch_if_safe() {
|
||||
local branch="$1"
|
||||
local ref="refs/heads/$branch"
|
||||
|
||||
if ! git show-ref --verify --quiet "$ref"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch_worktree=""
|
||||
branch_worktree=$(worktree_path_for_branch "$branch" 2>/dev/null || true)
|
||||
if [ -n "$branch_worktree" ]; then
|
||||
echo "Skipping local branch delete for $branch; checked out in worktree $branch_worktree"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git branch -D "$branch" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if git update-ref -d "$ref" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Warning: failed to delete local branch $branch"
|
||||
return 0
|
||||
}
|
||||
193
scripts/pr-lib/gates.sh
Normal file
193
scripts/pr-lib/gates.sh
Normal file
@@ -0,0 +1,193 @@
|
||||
run_hosted_prepare_gates() {
|
||||
local pr="$1"
|
||||
local current_head="$2"
|
||||
local changelog_only="$3"
|
||||
local remote_head
|
||||
remote_head=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
if [ "$remote_head" != "$current_head" ]; then
|
||||
echo "PR head changed before hosted gate verification (expected $current_head, got $remote_head). Re-run prepare-init."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local repo
|
||||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
||||
local args=(
|
||||
scripts/verify-pr-hosted-gates.mjs
|
||||
--repo "$repo"
|
||||
--sha "$current_head"
|
||||
--output ".local/gates-hosted-checks.json"
|
||||
)
|
||||
if [ "$changelog_only" = "true" ]; then
|
||||
args+=(--changelog-only)
|
||||
fi
|
||||
run_quiet_logged "exact-head hosted CI/Testbox gates" ".local/gates-hosted-checks.log" node "${args[@]}"
|
||||
}
|
||||
|
||||
pin_worktree_bundled_plugins_dir() {
|
||||
# Nested .worktrees/<pr> checkouts resolve vitest tooling from the primary
|
||||
# checkout's node_modules; pin bundled plugin discovery to this worktree so
|
||||
# PR branches without the openclaw-root node_modules-boundary fix still test
|
||||
# their own extensions instead of the primary checkout's stale trees.
|
||||
export OPENCLAW_BUNDLED_PLUGINS_DIR="${OPENCLAW_BUNDLED_PLUGINS_DIR:-$PWD/extensions}"
|
||||
}
|
||||
|
||||
run_prepare_push_retry_gates() {
|
||||
local docs_only="${1:-false}"
|
||||
|
||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||
echo "A lease retry changed the prepared head, so its exact-head hosted evidence no longer applies."
|
||||
echo "Stop here, wait for CI/Testbox on the pushed head, then re-run prepare-run."
|
||||
return 1
|
||||
fi
|
||||
|
||||
pin_worktree_bundled_plugins_dir
|
||||
bootstrap_deps_if_needed
|
||||
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
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_gates() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
|
||||
checkout_prep_branch "$pr"
|
||||
require_artifact .local/pr-meta.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
local changed_files
|
||||
changed_files=$(git diff --name-only origin/main...HEAD)
|
||||
local non_docs
|
||||
non_docs=$(printf '%s\n' "$changed_files" | while IFS= read -r path; do
|
||||
[ -n "$path" ] || continue
|
||||
if ! path_is_docsish "$path"; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done)
|
||||
|
||||
local docs_only=false
|
||||
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
|
||||
docs_only=true
|
||||
fi
|
||||
local changelog_only=false
|
||||
if [ "$changed_files" = "CHANGELOG.md" ]; then
|
||||
changelog_only=true
|
||||
fi
|
||||
|
||||
local changelog_required=false
|
||||
if changelog_required_for_changed_files "$changed_files"; then
|
||||
changelog_required=true
|
||||
fi
|
||||
|
||||
local has_changelog_update=false
|
||||
local unsupported_changelog_fragments=""
|
||||
local changed_path
|
||||
while IFS= read -r changed_path; do
|
||||
[ -n "$changed_path" ] || continue
|
||||
case "$changed_path" in
|
||||
CHANGELOG.md)
|
||||
has_changelog_update=true
|
||||
;;
|
||||
changelog/fragments/*)
|
||||
unsupported_changelog_fragments="${unsupported_changelog_fragments}${changed_path}"$'\n'
|
||||
;;
|
||||
esac
|
||||
done <<<"$changed_files"
|
||||
if [ -n "$unsupported_changelog_fragments" ]; then
|
||||
echo "Unsupported changelog fragment files detected:"
|
||||
printf '%s\n' "$unsupported_changelog_fragments"
|
||||
echo "Move changelog fragment content into CHANGELOG.md and remove changelog/fragments files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$has_changelog_update" = "true" ]; then
|
||||
normalize_pr_changelog_entries "$pr"
|
||||
validate_changelog_attribution_policy
|
||||
fi
|
||||
|
||||
if [ "$changelog_required" = "true" ]; then
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
validate_changelog_merge_hygiene
|
||||
validate_changelog_entry_for_pr "$pr" "$contrib"
|
||||
else
|
||||
echo "Changelog not required for this changed-file set."
|
||||
fi
|
||||
|
||||
local current_head
|
||||
current_head=$(git rev-parse HEAD)
|
||||
local previous_last_verified_head=""
|
||||
local previous_full_gates_head=""
|
||||
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:-}"
|
||||
fi
|
||||
|
||||
local gates_mode="full"
|
||||
local hosted_gates_head=""
|
||||
local reuse_gates=false
|
||||
if [ "${OPENCLAW_TESTBOX:-}" != "1" ] && [ "$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)
|
||||
if [ -z "$delta_since_verified" ] || file_list_is_docsish_only "$delta_since_verified"; then
|
||||
reuse_gates=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||
gates_mode="hosted_exact_head"
|
||||
if [ "$changelog_only" = "true" ]; then
|
||||
run_quiet_logged "git diff --check" ".local/gates-diff-check.log" git diff --check origin/main...HEAD
|
||||
fi
|
||||
run_hosted_prepare_gates "$pr" "$current_head" "$changelog_only"
|
||||
hosted_gates_head="$current_head"
|
||||
elif [ "$reuse_gates" = "true" ]; then
|
||||
gates_mode="reused_docs_only"
|
||||
echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates."
|
||||
else
|
||||
pin_worktree_bundled_plugins_dir
|
||||
bootstrap_deps_if_needed
|
||||
run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build
|
||||
run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check
|
||||
|
||||
if [ "$docs_only" = "true" ]; then
|
||||
gates_mode="docs_only"
|
||||
echo "Docs-only change detected with high confidence; skipping pnpm test."
|
||||
else
|
||||
gates_mode="full"
|
||||
if [ -n "${OPENCLAW_VITEST_MAX_WORKERS:-}" ]; then
|
||||
echo "Running pnpm test with OPENCLAW_VITEST_MAX_WORKERS=$OPENCLAW_VITEST_MAX_WORKERS."
|
||||
run_quiet_logged \
|
||||
"pnpm test" \
|
||||
".local/gates-test.log" \
|
||||
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" pnpm test
|
||||
else
|
||||
echo "Running pnpm test with host-aware scheduling defaults."
|
||||
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
|
||||
fi
|
||||
previous_full_gates_head="$current_head"
|
||||
fi
|
||||
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" \
|
||||
LAST_VERIFIED_HEAD_SHA "$current_head" \
|
||||
FULL_GATES_HEAD_SHA "${previous_full_gates_head:-}" \
|
||||
HOSTED_GATES_HEAD_SHA "$hosted_gates_head" \
|
||||
GATES_PASSED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> .local/gates.env
|
||||
|
||||
echo "docs_only=$docs_only"
|
||||
echo "changelog_only=$changelog_only"
|
||||
echo "changelog_required=$changelog_required"
|
||||
echo "gates_mode=$gates_mode"
|
||||
echo "wrote=.local/gates.env"
|
||||
}
|
||||
328
scripts/pr-lib/merge.sh
Normal file
328
scripts/pr-lib/merge.sh
Normal file
@@ -0,0 +1,328 @@
|
||||
is_mainline_drift_critical_path_for_merge() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|.npmrc|.oxlintrc.json|.oxfmtrc.json|tsconfig.json|tsconfig.*.json|vitest.config.ts|vitest.*.config.ts|scripts/*|.github/workflows/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
print_file_list_with_limit() {
|
||||
local label="$1"
|
||||
local file_path="$2"
|
||||
local limit="${3:-12}"
|
||||
|
||||
if [ ! -s "$file_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(wc -l < "$file_path" | tr -d ' ')
|
||||
echo "$label ($count):"
|
||||
sed -n "1,${limit}p" "$file_path" | sed 's/^/ - /'
|
||||
if [ "$count" -gt "$limit" ]; then
|
||||
echo " ... +$((count - limit)) more"
|
||||
fi
|
||||
}
|
||||
|
||||
mainline_drift_requires_sync() {
|
||||
local mainline_base="$1"
|
||||
local prepared_head_sha="$2"
|
||||
|
||||
if ! git cat-file -e "${mainline_base}^{commit}" 2>/dev/null; then
|
||||
echo "Mainline drift relevance: mainline base $mainline_base is missing locally; require sync."
|
||||
return 0
|
||||
fi
|
||||
if ! git cat-file -e "${prepared_head_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Mainline drift relevance: prepared head $prepared_head_sha is missing locally; require sync."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local delta_file
|
||||
local prepared_files_file
|
||||
local overlap_file
|
||||
local critical_file
|
||||
delta_file=$(mktemp)
|
||||
prepared_files_file=$(mktemp)
|
||||
overlap_file=$(mktemp)
|
||||
critical_file=$(mktemp)
|
||||
|
||||
# Compare only mainline commits since the prepared lineage base. The remote
|
||||
# GraphQL commit has a different parent but its verified tree shares this
|
||||
# lineage, so its PR files must not look like incoming mainline drift.
|
||||
git diff --name-only "${mainline_base}..origin/main" | sed '/^$/d' | sort -u > "$delta_file"
|
||||
git diff --name-only "${mainline_base}..${prepared_head_sha}" | sed '/^$/d' | sort -u > "$prepared_files_file"
|
||||
comm -12 "$delta_file" "$prepared_files_file" > "$overlap_file" || true
|
||||
|
||||
local path
|
||||
while IFS= read -r path; do
|
||||
[ -n "$path" ] || continue
|
||||
if is_mainline_drift_critical_path_for_merge "$path"; then
|
||||
printf '%s\n' "$path" >> "$critical_file"
|
||||
fi
|
||||
done < "$delta_file"
|
||||
|
||||
local delta_count
|
||||
local overlap_count
|
||||
local critical_count
|
||||
delta_count=$(wc -l < "$delta_file" | tr -d ' ')
|
||||
overlap_count=$(wc -l < "$overlap_file" | tr -d ' ')
|
||||
critical_count=$(wc -l < "$critical_file" | tr -d ' ')
|
||||
|
||||
if [ "$delta_count" -eq 0 ]; then
|
||||
echo "Mainline drift relevance: no mainline changes since the prepared base."
|
||||
rm -f "$delta_file" "$prepared_files_file" "$overlap_file" "$critical_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$overlap_count" -gt 0 ] || [ "$critical_count" -gt 0 ]; then
|
||||
echo "Mainline drift relevance: sync required before merge."
|
||||
print_file_list_with_limit "Mainline files overlapping prepared files" "$overlap_file"
|
||||
print_file_list_with_limit "Mainline files touching merge-critical infrastructure" "$critical_file"
|
||||
rm -f "$delta_file" "$prepared_files_file" "$overlap_file" "$critical_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Mainline drift relevance: no overlap with prepared files and no critical infra drift."
|
||||
print_file_list_with_limit "Mainline-only drift files" "$delta_file"
|
||||
rm -f "$delta_file" "$prepared_files_file" "$overlap_file" "$critical_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
merge_verify() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
|
||||
require_artifact .local/prep.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep.env
|
||||
verify_prep_branch_matches_prepared_head "$pr" "${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}"
|
||||
|
||||
local json
|
||||
json=$(pr_meta_json "$pr")
|
||||
local is_draft
|
||||
is_draft=$(printf '%s\n' "$json" | jq -r .isDraft)
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
echo "PR is draft."
|
||||
exit 1
|
||||
fi
|
||||
local pr_head_sha
|
||||
pr_head_sha=$(printf '%s\n' "$json" | jq -r .headRefOid)
|
||||
|
||||
if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
|
||||
echo "PR head changed after prepare (expected $PREP_HEAD_SHA, got $pr_head_sha)."
|
||||
echo "Re-run prepare to refresh prep artifacts and gates: scripts/pr-prepare run $pr"
|
||||
echo "Note: docs/changelog-only follow-ups reuse prior gate results automatically."
|
||||
|
||||
git fetch origin "pull/$pr/head" >/dev/null 2>&1 || true
|
||||
if git cat-file -e "${PREP_HEAD_SHA}^{commit}" 2>/dev/null && git cat-file -e "${pr_head_sha}^{commit}" 2>/dev/null; then
|
||||
echo "HEAD delta (expected...current):"
|
||||
git log --oneline --left-right "${PREP_HEAD_SHA}...${pr_head_sha}" | sed 's/^/ /' || true
|
||||
else
|
||||
echo "HEAD delta unavailable locally (could not resolve one of the SHAs)."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh pr checks "$pr" --required --watch --fail-fast >.local/merge-checks-watch.log 2>&1 || true
|
||||
local checks_json
|
||||
local checks_err_file
|
||||
checks_err_file=$(mktemp)
|
||||
checks_json=$(gh pr checks "$pr" --required --json name,bucket,state 2>"$checks_err_file" || true)
|
||||
rm -f "$checks_err_file"
|
||||
if [ -z "$checks_json" ]; then
|
||||
checks_json='[]'
|
||||
fi
|
||||
local required_count
|
||||
required_count=$(printf '%s\n' "$checks_json" | jq 'length')
|
||||
if [ "$required_count" -eq 0 ]; then
|
||||
echo "No required checks configured for this PR."
|
||||
fi
|
||||
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
|
||||
|
||||
local failed_required
|
||||
failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
|
||||
local pending_required
|
||||
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
|
||||
|
||||
if [ "$failed_required" -gt 0 ]; then
|
||||
echo "Required checks are failing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$pending_required" -gt 0 ]; then
|
||||
echo "Required checks are still pending."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin "pull/$pr/head:pr-$pr" --force
|
||||
if ! git merge-base --is-ancestor origin/main "pr-$pr"; then
|
||||
echo "PR branch is behind main."
|
||||
if mainline_drift_requires_sync \
|
||||
"${PREP_MAINLINE_BASE_SHA:-${LOCAL_PREP_HEAD_SHA:-$PREP_HEAD_SHA}}" \
|
||||
"$PREP_HEAD_SHA"
|
||||
then
|
||||
echo "Merge verify failed: mainline drift is relevant to this PR; run scripts/pr prepare-sync-head $pr before merge."
|
||||
exit 1
|
||||
fi
|
||||
echo "Merge verify: continuing without prep-head sync because behind-main drift is unrelated."
|
||||
fi
|
||||
|
||||
echo "merge-verify passed for PR #$pr"
|
||||
}
|
||||
|
||||
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
|
||||
require_artifact "$required"
|
||||
done
|
||||
|
||||
merge_verify "$pr"
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep.env
|
||||
|
||||
local pr_meta_json
|
||||
pr_meta_json=$(gh pr view "$pr" --json state,isDraft)
|
||||
local is_draft
|
||||
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
|
||||
if [ "$is_draft" = "true" ]; then
|
||||
echo "PR is draft; stop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
delete_remote_pr_head_branch_after_merge() {
|
||||
local head_json
|
||||
head_json=$(gh pr view "$pr" --json headRefName,headRepository,headRepositoryOwner,isCrossRepository,maintainerCanModify)
|
||||
|
||||
local head_ref
|
||||
head_ref=$(printf '%s\n' "$head_json" | jq -r '.headRefName // ""')
|
||||
if [ -z "$head_ref" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local repo_owner
|
||||
repo_owner=$(printf '%s\n' "$head_json" | jq -r '.headRepositoryOwner.login // ""')
|
||||
local repo_name
|
||||
repo_name=$(printf '%s\n' "$head_json" | jq -r '.headRepository.name // ""')
|
||||
if [ -z "$repo_owner" ] || [ -z "$repo_name" ]; then
|
||||
echo "Warning: unable to resolve head repository for remote branch cleanup"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local encoded_ref
|
||||
encoded_ref=$(jq -rn --arg value "heads/$head_ref" '$value|@uri')
|
||||
if gh api -X DELETE "repos/$repo_owner/$repo_name/git/refs/$encoded_ref" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Warning: failed to delete remote branch $repo_owner/$repo_name:$head_ref"
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! gh pr merge "$pr" \
|
||||
--squash \
|
||||
--match-head-commit "$PREP_HEAD_SHA" \
|
||||
>.local/merge-output.log 2>&1
|
||||
then
|
||||
print_relevant_log_excerpt .local/merge-output.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local state
|
||||
state=$(gh pr view "$pr" --json state --jq .state)
|
||||
if [ "$state" != "MERGED" ]; then
|
||||
echo "Landing not finalized yet (state=$state), waiting up to 15 minutes..."
|
||||
local i
|
||||
for i in $(seq 1 90); do
|
||||
sleep 10
|
||||
state=$(gh pr view "$pr" --json state --jq .state)
|
||||
if [ "$state" = "MERGED" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$state" != "MERGED" ]; then
|
||||
echo "PR state is $state after waiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local landed_sha
|
||||
landed_sha=$(gh pr view "$pr" --json mergeCommit --jq '.mergeCommit.oid')
|
||||
if [ -z "$landed_sha" ] || [ "$landed_sha" = "null" ]; then
|
||||
echo "Landed commit SHA missing."
|
||||
exit 1
|
||||
fi
|
||||
local repo_nwo
|
||||
repo_nwo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
||||
|
||||
local landed_sha_url=""
|
||||
if gh api repos/:owner/:repo/commits/"$landed_sha" >/dev/null 2>&1; then
|
||||
landed_sha_url="https://github.com/$repo_nwo/commit/$landed_sha"
|
||||
else
|
||||
echo "Landed commit is not resolvable via repository commit endpoint: $landed_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local prep_sha_url=""
|
||||
if gh api repos/:owner/:repo/commits/"$PREP_HEAD_SHA" >/dev/null 2>&1; then
|
||||
prep_sha_url="https://github.com/$repo_nwo/commit/$PREP_HEAD_SHA"
|
||||
else
|
||||
local pr_commit_count
|
||||
pr_commit_count=$(gh pr view "$pr" --json commits --jq "[.commits[].oid | select(. == \"$PREP_HEAD_SHA\")] | length")
|
||||
if [ "${pr_commit_count:-0}" -gt 0 ]; then
|
||||
prep_sha_url="https://github.com/$repo_nwo/pull/$pr/commits/$PREP_HEAD_SHA"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$prep_sha_url" ]; then
|
||||
echo "Prepared head SHA is not resolvable in repo commits or PR commit list: $PREP_HEAD_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local ok=0
|
||||
local comment_output=""
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
if comment_output=$(
|
||||
{
|
||||
echo "Merged via squash."
|
||||
echo
|
||||
echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)"
|
||||
echo "- Landed commit: [$landed_sha]($landed_sha_url)"
|
||||
} | gh pr comment "$pr" -F - 2>&1
|
||||
); then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$ok" -eq 1 ] || { echo "Failed to post PR comment after retries"; exit 1; }
|
||||
|
||||
local comment_url=""
|
||||
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
|
||||
if [ -z "$comment_url" ]; then
|
||||
comment_url="unresolved"
|
||||
fi
|
||||
|
||||
local root
|
||||
root=$(repo_root)
|
||||
cd "$root"
|
||||
delete_remote_pr_head_branch_after_merge
|
||||
remove_worktree_if_present ".worktrees/pr-$pr"
|
||||
delete_local_branch_if_safe "temp/pr-$pr"
|
||||
delete_local_branch_if_safe "pr-$pr"
|
||||
delete_local_branch_if_safe "pr-$pr-prep"
|
||||
|
||||
local pr_url
|
||||
pr_url=$(gh pr view "$pr" --json url --jq .url)
|
||||
|
||||
echo "merge-run complete for PR #$pr"
|
||||
echo "landed commit: $landed_sha"
|
||||
echo "completion comment: $comment_url"
|
||||
echo "$pr_url"
|
||||
}
|
||||
340
scripts/pr-lib/prepare-core.sh
Normal file
340
scripts/pr-lib/prepare-core.sh
Normal file
@@ -0,0 +1,340 @@
|
||||
checkout_prep_branch() {
|
||||
local pr="$1"
|
||||
require_artifact .local/prep-context.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
|
||||
local prep_branch
|
||||
prep_branch=$(resolve_prep_branch_name "$pr")
|
||||
git checkout "$prep_branch"
|
||||
}
|
||||
|
||||
resolve_prep_branch_name() {
|
||||
local pr="$1"
|
||||
require_artifact .local/prep-context.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
|
||||
local prep_branch="${PREP_BRANCH:-pr-$pr-prep}"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$prep_branch"; then
|
||||
echo "Expected prep branch $prep_branch not found. Run prepare-init first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$prep_branch"
|
||||
}
|
||||
|
||||
verify_prep_branch_matches_prepared_head() {
|
||||
local pr="$1"
|
||||
local prepared_head_sha="$2"
|
||||
|
||||
local prep_branch
|
||||
prep_branch=$(resolve_prep_branch_name "$pr")
|
||||
local prep_branch_head_sha
|
||||
prep_branch_head_sha=$(git rev-parse "refs/heads/$prep_branch")
|
||||
if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Local prep branch moved after prepare-push (branch=$prep_branch expected $prepared_head_sha, got $prep_branch_head_sha)."
|
||||
if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then
|
||||
echo "Unpushed local commits on prep branch:"
|
||||
git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true
|
||||
echo "Run scripts/pr prepare-sync-head $pr to push them before merge."
|
||||
else
|
||||
echo "Prep branch no longer contains the prepared head. Re-run prepare-init."
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
prepare_init() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" true
|
||||
|
||||
require_artifact .local/pr-meta.env
|
||||
require_artifact .local/review.md
|
||||
|
||||
if [ ! -s .local/review.json ]; then
|
||||
echo "WARNING: .local/review.json is missing; structured findings are expected."
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
local json
|
||||
json=$(pr_meta_json "$pr")
|
||||
|
||||
local head
|
||||
head=$(printf '%s\n' "$json" | jq -r .headRefName)
|
||||
local pr_head_sha_before
|
||||
pr_head_sha_before=$(printf '%s\n' "$json" | jq -r .headRefOid)
|
||||
|
||||
if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then
|
||||
echo "PR head branch changed from $PR_HEAD to $head. Re-run review-pr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin "pull/$pr/head:pr-$pr" --force
|
||||
git checkout -B "pr-$pr-prep" "pr-$pr"
|
||||
git fetch origin main
|
||||
|
||||
# Security: shell-escape values to prevent command injection via malicious branch names.
|
||||
printf '%s=%q\n' \
|
||||
PR_NUMBER "$pr" \
|
||||
PR_HEAD "$head" \
|
||||
PR_HEAD_SHA_BEFORE "$pr_head_sha_before" \
|
||||
PREP_BRANCH "pr-$pr-prep" \
|
||||
PREP_STARTED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> .local/prep-context.env
|
||||
|
||||
if [ ! -f .local/prep.md ]; then
|
||||
cat > .local/prep.md <<EOF_PREP
|
||||
# PR $pr prepare log
|
||||
|
||||
- Initialized prepare context from the PR head branch without rebasing on origin/main.
|
||||
EOF_PREP
|
||||
fi
|
||||
|
||||
echo "worktree=$PWD"
|
||||
echo "branch=$(git branch --show-current)"
|
||||
echo "wrote=.local/prep-context.env .local/prep.md"
|
||||
}
|
||||
|
||||
prepare_validate_commit() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
require_artifact .local/pr-meta.env
|
||||
|
||||
checkout_prep_branch "$pr"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
local pr_number="${PR_NUMBER:-$pr}"
|
||||
|
||||
local subject
|
||||
subject=$(git log -1 --pretty=%s)
|
||||
|
||||
if echo "$subject" | rg -qi "(^|[[:space:]])openclaw#$pr_number([[:space:]]|$)|\\(#$pr_number\\)"; then
|
||||
echo "ERROR: prep commit subject should not include PR number metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if echo "$subject" | rg -qi "thanks @"; then
|
||||
echo "ERROR: prep commit subject should not include contributor thanks"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "prep commit subject validated: $subject"
|
||||
}
|
||||
|
||||
prepare_push() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
|
||||
require_artifact .local/pr-meta.env
|
||||
require_artifact .local/prep-context.env
|
||||
require_artifact .local/gates.env
|
||||
|
||||
checkout_prep_branch "$pr"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/gates.env
|
||||
|
||||
local prep_head_sha
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
local local_prep_head_sha
|
||||
|
||||
local lease_sha
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
local push_result_env=".local/prepare-push-result.env"
|
||||
|
||||
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
|
||||
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" true "${DOCS_ONLY:-false}" "$push_result_env"
|
||||
# shellcheck disable=SC1090
|
||||
source "$push_result_env"
|
||||
prep_head_sha="$PUSH_PREP_HEAD_SHA"
|
||||
local_prep_head_sha="$PUSH_LOCAL_PREP_HEAD_SHA"
|
||||
local mainline_base_sha
|
||||
mainline_base_sha=$(git merge-base "$local_prep_head_sha" origin/main) || {
|
||||
echo "Unable to resolve the prepared mainline base."
|
||||
exit 1
|
||||
}
|
||||
if [ -s .local/prep-sync.env ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-sync.env
|
||||
local current_prep_tree
|
||||
current_prep_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
|
||||
if [ "${PREP_SYNC_TREE:-}" != "$current_prep_tree" ] || [ -z "${PREP_SYNC_MAINLINE_BASE_SHA:-}" ]; then
|
||||
echo "Prepared PR head no longer matches the verified sync tree."
|
||||
exit 1
|
||||
fi
|
||||
mainline_base_sha="$PREP_SYNC_MAINLINE_BASE_SHA"
|
||||
rm -f .local/prep-sync.env
|
||||
fi
|
||||
local pushed_from_sha="$PUSHED_FROM_SHA"
|
||||
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
|
||||
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
if [ -z "$contrib" ]; then
|
||||
contrib=$(gh pr view "$pr" --json author --jq .author.login)
|
||||
fi
|
||||
local coauthor_email=""
|
||||
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
|
||||
:
|
||||
else
|
||||
coauthor_email=""
|
||||
fi
|
||||
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Gates passed and push succeeded to branch $PR_HEAD.
|
||||
- Gate mode: ${GATES_MODE:-unknown}.
|
||||
- Verified the remote PR head tree matches the local prep head.
|
||||
EOF_PREP
|
||||
|
||||
# Security: shell-escape values to prevent command injection via propagated PR_HEAD.
|
||||
printf '%s=%q\n' \
|
||||
PR_NUMBER "$PR_NUMBER" \
|
||||
PR_AUTHOR "$contrib" \
|
||||
PR_URL "${PR_URL:-}" \
|
||||
PR_HEAD "$PR_HEAD" \
|
||||
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
|
||||
PREP_HEAD_SHA "$prep_head_sha" \
|
||||
LOCAL_PREP_HEAD_SHA "$local_prep_head_sha" \
|
||||
PREP_MAINLINE_BASE_SHA "$mainline_base_sha" \
|
||||
COAUTHOR_EMAIL "$coauthor_email" \
|
||||
> .local/prep.env
|
||||
|
||||
ls -la .local/prep.md .local/prep.env >/dev/null
|
||||
|
||||
echo "prepare-push complete"
|
||||
echo "pr_url=${PR_URL:-}"
|
||||
echo "prep_branch=$(git branch --show-current)"
|
||||
echo "prep_head_sha=$prep_head_sha"
|
||||
echo "pr_head_sha=$pr_head_sha_after"
|
||||
echo "artifacts=.local/prep.md .local/prep.env"
|
||||
}
|
||||
|
||||
prepare_sync_head() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
|
||||
require_artifact .local/pr-meta.env
|
||||
require_artifact .local/prep-context.env
|
||||
|
||||
checkout_prep_branch "$pr"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
|
||||
local rebased=false
|
||||
git fetch origin main
|
||||
if ! git merge-base --is-ancestor origin/main HEAD; then
|
||||
git rebase origin/main
|
||||
rebased=true
|
||||
if [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||
rm -f .local/gates.env .local/prep.env
|
||||
echo "Rebased head requires fresh exact-head hosted CI/Testbox evidence after push."
|
||||
else
|
||||
prepare_gates "$pr"
|
||||
checkout_prep_branch "$pr"
|
||||
fi
|
||||
fi
|
||||
|
||||
local prep_head_sha
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
local local_prep_head_sha
|
||||
|
||||
local lease_sha
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
local push_result_env=".local/prepare-sync-result.env"
|
||||
|
||||
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
|
||||
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" false false "$push_result_env"
|
||||
# shellcheck disable=SC1090
|
||||
source "$push_result_env"
|
||||
prep_head_sha="$PUSH_PREP_HEAD_SHA"
|
||||
local_prep_head_sha="$PUSH_LOCAL_PREP_HEAD_SHA"
|
||||
local mainline_base_sha
|
||||
mainline_base_sha=$(git merge-base "$local_prep_head_sha" origin/main) || {
|
||||
echo "Unable to resolve the prepared mainline base."
|
||||
exit 1
|
||||
}
|
||||
local pushed_from_sha="$PUSHED_FROM_SHA"
|
||||
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
|
||||
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
if [ -z "$contrib" ]; then
|
||||
contrib=$(gh pr view "$pr" --json author --jq .author.login)
|
||||
fi
|
||||
local coauthor_email=""
|
||||
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
|
||||
:
|
||||
else
|
||||
coauthor_email=""
|
||||
fi
|
||||
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Prep head sync completed to branch $PR_HEAD.
|
||||
- Rebased onto origin/main: $rebased.
|
||||
- Verified the remote PR head tree matches the local prep head.
|
||||
EOF_PREP
|
||||
|
||||
if [ "$rebased" = "true" ] && [ "${OPENCLAW_TESTBOX:-}" = "1" ]; then
|
||||
local prep_sync_tree
|
||||
prep_sync_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
|
||||
# Preserve the verified local lineage because GraphQL creates a remote
|
||||
# commit with the same tree but the old branch parent.
|
||||
printf '%s=%q\n' \
|
||||
PREP_SYNC_MAINLINE_BASE_SHA "$mainline_base_sha" \
|
||||
PREP_SYNC_TREE "$prep_sync_tree" \
|
||||
> .local/prep-sync.env
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Cleared stale prepare artifacts. Wait for hosted CI/Testbox on $prep_head_sha, then run prepare-run again.
|
||||
EOF_PREP
|
||||
echo "prepare-sync-head complete"
|
||||
echo "prep_head_sha=$prep_head_sha"
|
||||
echo "Hosted CI/Testbox must pass for this exact head before prepare-run can continue."
|
||||
return
|
||||
fi
|
||||
|
||||
cat >> .local/prep.md <<EOF_PREP
|
||||
- Prepare gates reran automatically when the sync rebase changed the prep head.
|
||||
EOF_PREP
|
||||
|
||||
# Security: shell-escape values to prevent command injection via propagated PR_HEAD.
|
||||
printf '%s=%q\n' \
|
||||
PR_NUMBER "$PR_NUMBER" \
|
||||
PR_AUTHOR "$contrib" \
|
||||
PR_URL "${PR_URL:-}" \
|
||||
PR_HEAD "$PR_HEAD" \
|
||||
PR_HEAD_SHA_BEFORE "$pushed_from_sha" \
|
||||
PREP_HEAD_SHA "$prep_head_sha" \
|
||||
LOCAL_PREP_HEAD_SHA "$local_prep_head_sha" \
|
||||
PREP_MAINLINE_BASE_SHA "$mainline_base_sha" \
|
||||
COAUTHOR_EMAIL "$coauthor_email" \
|
||||
> .local/prep.env
|
||||
|
||||
ls -la .local/prep.md .local/prep.env >/dev/null
|
||||
|
||||
echo "prepare-sync-head complete"
|
||||
echo "pr_url=${PR_URL:-}"
|
||||
echo "prep_branch=$(git branch --show-current)"
|
||||
echo "prep_head_sha=$prep_head_sha"
|
||||
echo "pr_head_sha=$pr_head_sha_after"
|
||||
echo "artifacts=.local/prep.md .local/prep.env"
|
||||
}
|
||||
|
||||
prepare_run() {
|
||||
local pr="$1"
|
||||
prepare_init "$pr"
|
||||
prepare_gates "$pr"
|
||||
prepare_push "$pr"
|
||||
echo "prepare-run complete for PR #$pr"
|
||||
echo "pr_url=${PR_URL:-}"
|
||||
}
|
||||
348
scripts/pr-lib/push.sh
Normal file
348
scripts/pr-lib/push.sh
Normal file
@@ -0,0 +1,348 @@
|
||||
resolve_head_push_url() {
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
printf 'https://github.com/%s/%s.git\n' "$PR_HEAD_OWNER" "$PR_HEAD_REPO_NAME"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "${PR_HEAD_REPO_URL:-}" ] && [ "$PR_HEAD_REPO_URL" != "null" ]; then
|
||||
case "$PR_HEAD_REPO_URL" in
|
||||
*.git) printf '%s\n' "$PR_HEAD_REPO_URL" ;;
|
||||
*) printf '%s.git\n' "$PR_HEAD_REPO_URL" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Push to a fork PR branch via GitHub GraphQL createCommitOnBranch.
|
||||
# This uses the same permission model as the GitHub web editor, bypassing
|
||||
# the git-protocol 403 that occurs even when maintainer_can_modify is true.
|
||||
# Usage: graphql_push_to_fork <owner/repo> <branch> <expected_head_oid>
|
||||
# Pushes the diff between expected_head_oid and local HEAD as file additions/deletions.
|
||||
# File bytes are read from git objects (not the working tree) to avoid
|
||||
# symlink/special-file dereference risks from untrusted fork content.
|
||||
graphql_push_to_fork() {
|
||||
local repo_nwo="$1"
|
||||
local branch="$2"
|
||||
local expected_oid="$3"
|
||||
local max_blob_bytes=$((5 * 1024 * 1024))
|
||||
|
||||
local additions="[]"
|
||||
local deletions="[]"
|
||||
|
||||
local added_files
|
||||
added_files=$(git diff --no-renames --name-only --diff-filter=AM "$expected_oid" HEAD)
|
||||
if [ -n "$added_files" ]; then
|
||||
additions="["
|
||||
local first=true
|
||||
while IFS= read -r fpath; do
|
||||
[ -n "$fpath" ] || continue
|
||||
|
||||
local tree_entry
|
||||
tree_entry=$(git ls-tree HEAD -- "$fpath")
|
||||
if [ -z "$tree_entry" ]; then
|
||||
echo "GraphQL push could not resolve path in HEAD tree: $fpath" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local file_mode
|
||||
file_mode=$(printf '%s\n' "$tree_entry" | awk '{print $1}')
|
||||
local file_type
|
||||
file_type=$(printf '%s\n' "$tree_entry" | awk '{print $2}')
|
||||
local file_oid
|
||||
file_oid=$(printf '%s\n' "$tree_entry" | awk '{print $3}')
|
||||
|
||||
if [ "$file_type" != "blob" ] || [ "$file_mode" = "160000" ]; then
|
||||
echo "GraphQL push only supports blob files; refusing $fpath (mode=$file_mode type=$file_type)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local blob_size
|
||||
blob_size=$(git cat-file -s "$file_oid")
|
||||
if [ "$blob_size" -gt "$max_blob_bytes" ]; then
|
||||
echo "GraphQL push refused large file $fpath (${blob_size} bytes > ${max_blob_bytes})" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local b64
|
||||
b64=$(git cat-file -p "$file_oid" | base64 | tr -d '\n')
|
||||
if [ "$first" = true ]; then first=false; else additions+=","; fi
|
||||
additions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .),\"contents\":$(printf '%s' "$b64" | jq -Rs .)}"
|
||||
done <<< "$added_files"
|
||||
additions+="]"
|
||||
fi
|
||||
|
||||
local deleted_files
|
||||
deleted_files=$(git diff --no-renames --name-only --diff-filter=D "$expected_oid" HEAD)
|
||||
if [ -n "$deleted_files" ]; then
|
||||
deletions="["
|
||||
local first=true
|
||||
while IFS= read -r fpath; do
|
||||
[ -n "$fpath" ] || continue
|
||||
if [ "$first" = true ]; then first=false; else deletions+=","; fi
|
||||
deletions+="{\"path\":$(printf '%s' "$fpath" | jq -Rs .)}"
|
||||
done <<< "$deleted_files"
|
||||
deletions+="]"
|
||||
fi
|
||||
|
||||
local commit_headline
|
||||
commit_headline=$(git log -1 --format=%s HEAD)
|
||||
|
||||
local query
|
||||
query=$(cat <<'GRAPHQL'
|
||||
mutation($input: CreateCommitOnBranchInput!) {
|
||||
createCommitOnBranch(input: $input) {
|
||||
commit { oid url }
|
||||
}
|
||||
}
|
||||
GRAPHQL
|
||||
)
|
||||
|
||||
local additions_file deletions_file
|
||||
additions_file=$(mktemp)
|
||||
deletions_file=$(mktemp)
|
||||
printf '%s\n' "$additions" >"$additions_file"
|
||||
printf '%s\n' "$deletions" >"$deletions_file"
|
||||
|
||||
local variables
|
||||
variables=$(jq -n \
|
||||
--arg nwo "$repo_nwo" \
|
||||
--arg branch "$branch" \
|
||||
--arg oid "$expected_oid" \
|
||||
--arg headline "$commit_headline" \
|
||||
--slurpfile additions "$additions_file" \
|
||||
--slurpfile deletions "$deletions_file" \
|
||||
'{input: {
|
||||
branch: { repositoryNameWithOwner: $nwo, branchName: $branch },
|
||||
message: { headline: $headline },
|
||||
fileChanges: { additions: $additions[0], deletions: $deletions[0] },
|
||||
expectedHeadOid: $oid
|
||||
}}')
|
||||
rm -f "$additions_file" "$deletions_file"
|
||||
|
||||
local variables_file
|
||||
variables_file=$(mktemp)
|
||||
printf '%s\n' "$variables" >"$variables_file"
|
||||
|
||||
local payload
|
||||
payload=$(jq -n --arg query "$query" --slurpfile variables "$variables_file" \
|
||||
'{query: $query, variables: $variables[0]}')
|
||||
rm -f "$variables_file"
|
||||
|
||||
local result
|
||||
result=$(gh api graphql --input - <<< "$payload" 2>&1) || {
|
||||
echo "GraphQL push failed: $result" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
local new_oid
|
||||
new_oid=$(printf '%s' "$result" | jq -r '.data.createCommitOnBranch.commit.oid // empty')
|
||||
if [ -z "$new_oid" ]; then
|
||||
echo "GraphQL push returned no commit OID: $result" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "GraphQL push succeeded: $new_oid" >&2
|
||||
printf '%s\n' "$new_oid"
|
||||
}
|
||||
|
||||
resolve_head_push_url_https() {
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
printf 'https://github.com/%s/%s.git\n' "$PR_HEAD_OWNER" "$PR_HEAD_REPO_NAME"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "${PR_HEAD_REPO_URL:-}" ] && [ "$PR_HEAD_REPO_URL" != "null" ]; then
|
||||
case "$PR_HEAD_REPO_URL" in
|
||||
*.git) printf '%s\n' "$PR_HEAD_REPO_URL" ;;
|
||||
*) printf '%s.git\n' "$PR_HEAD_REPO_URL" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_pr_head_branch_matches_expected() {
|
||||
local pr="$1"
|
||||
local expected_head="$2"
|
||||
|
||||
local current_head
|
||||
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
|
||||
if [ "$current_head" != "$expected_head" ]; then
|
||||
echo "PR head branch changed from $expected_head to $current_head. Re-run prepare-init."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_prhead_remote() {
|
||||
local push_url
|
||||
push_url=$(resolve_head_push_url) || {
|
||||
echo "Unable to resolve PR head repo push URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
git remote remove prhead 2>/dev/null || true
|
||||
git remote add prhead "$push_url"
|
||||
}
|
||||
|
||||
resolve_prhead_remote_sha() {
|
||||
local pr_head="$1"
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
|
||||
if [ -z "$remote_sha" ]; then
|
||||
local https_url
|
||||
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
|
||||
local current_push_url
|
||||
current_push_url=$(git remote get-url prhead 2>/dev/null || true)
|
||||
if [ -n "$https_url" ] && [ "$https_url" != "$current_push_url" ]; then
|
||||
echo "SSH remote failed; falling back to HTTPS..." >&2
|
||||
git remote set-url prhead "$https_url"
|
||||
git remote set-url --push prhead "$https_url"
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
|
||||
fi
|
||||
if [ -z "$remote_sha" ]; then
|
||||
echo "Remote branch refs/heads/$pr_head not found on prhead" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$remote_sha"
|
||||
}
|
||||
|
||||
push_prep_head_once() {
|
||||
local pr_head="$1"
|
||||
local lease_sha="$2"
|
||||
local prep_head_sha="$3"
|
||||
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ] && [ "${OPENCLAW_PR_PUSH_MODE:-graphql}" != "git" ]; then
|
||||
echo "Pushing PR branch through GitHub createCommitOnBranch so the prepared commit is verified." >&2
|
||||
graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ "${OPENCLAW_ALLOW_UNSIGNED_GIT_PUSH:-}" != "1" ]; then
|
||||
echo "Refusing git-protocol PR branch push because it can publish unsigned commits." >&2
|
||||
echo "Use the default GitHub createCommitOnBranch path, or set OPENCLAW_ALLOW_UNSIGNED_GIT_PUSH=1 for an explicit manual override." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head >&2
|
||||
printf '%s\n' "$prep_head_sha"
|
||||
}
|
||||
|
||||
push_prep_head_to_pr_branch() {
|
||||
local pr="$1"
|
||||
local pr_head="$2"
|
||||
local prep_head_sha="$3"
|
||||
local lease_sha="$4"
|
||||
local rerun_gates_on_lease_retry="${5:-false}"
|
||||
local docs_only="${6:-false}"
|
||||
local result_env_path="${7:-.local/push-result.env}"
|
||||
local local_prep_head_sha="$prep_head_sha"
|
||||
|
||||
setup_prhead_remote
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(resolve_prhead_remote_sha "$pr_head")
|
||||
|
||||
local pushed_from_sha="$remote_sha"
|
||||
if [ "$remote_sha" = "$prep_head_sha" ]; then
|
||||
echo "Remote branch already at local prep HEAD; skipping push."
|
||||
else
|
||||
if [ "$remote_sha" != "$lease_sha" ]; then
|
||||
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
|
||||
lease_sha="$remote_sha"
|
||||
fi
|
||||
pushed_from_sha="$lease_sha"
|
||||
local push_output
|
||||
if ! push_output=$(push_prep_head_once "$pr_head" "$lease_sha" "$prep_head_sha" 2>&1); then
|
||||
echo "Push failed: $push_output"
|
||||
|
||||
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
|
||||
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ "$rerun_gates_on_lease_retry" != "true" ]; then
|
||||
echo "PR head changed during sync; re-run prepare-sync-head from the refreshed branch."
|
||||
exit 1
|
||||
fi
|
||||
echo "Lease push failed, retrying once with fresh PR head..."
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
pushed_from_sha="$lease_sha"
|
||||
|
||||
if [ "$rerun_gates_on_lease_retry" = "true" ]; then
|
||||
git fetch origin "pull/$pr/head:pr-$pr-latest" --force
|
||||
git rebase "pr-$pr-latest"
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
local_prep_head_sha="$prep_head_sha"
|
||||
run_prepare_push_retry_gates "$docs_only"
|
||||
fi
|
||||
|
||||
if ! push_output=$(push_prep_head_once "$pr_head" "$lease_sha" "$prep_head_sha" 2>&1); then
|
||||
echo "Retry push failed: $push_output"
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
echo "Retry failed; trying GraphQL createCommitOnBranch fallback..."
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
prep_head_sha=$(printf '%s\n' "$push_output" | tail -n 1)
|
||||
fi
|
||||
fi
|
||||
else
|
||||
prep_head_sha=$(printf '%s\n' "$push_output" | tail -n 1)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
|
||||
local observed_sha
|
||||
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pr_head_sha_after
|
||||
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
|
||||
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
|
||||
local local_prep_tree
|
||||
local remote_prep_tree
|
||||
local_prep_tree=$(git rev-parse "${local_prep_head_sha}^{tree}")
|
||||
remote_prep_tree=$(git rev-parse "pr-$pr-verify^{tree}")
|
||||
git branch -D "pr-$pr-verify" 2>/dev/null || true
|
||||
if [ "$local_prep_tree" != "$remote_prep_tree" ]; then
|
||||
echo "Pushed PR head tree differs from the prepared local tree."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# merge-verify owns relevance-aware mainline drift checks. Requiring every
|
||||
# prepared head to contain main here forces needless rebases, while GraphQL
|
||||
# createCommitOnBranch cannot move a rebased branch's commit ancestry.
|
||||
# Security: shell-escape values to prevent command injection when sourced.
|
||||
printf '%s=%q\n' \
|
||||
PUSH_PREP_HEAD_SHA "$prep_head_sha" \
|
||||
PUSH_LOCAL_PREP_HEAD_SHA "$local_prep_head_sha" \
|
||||
PUSHED_FROM_SHA "$pushed_from_sha" \
|
||||
PR_HEAD_SHA_AFTER_PUSH "$pr_head_sha_after" \
|
||||
> "$result_env_path"
|
||||
}
|
||||
527
scripts/pr-lib/review.sh
Normal file
527
scripts/pr-lib/review.sh
Normal file
@@ -0,0 +1,527 @@
|
||||
set_review_mode() {
|
||||
local mode="$1"
|
||||
# Security: shell-escape values to prevent command injection when sourced.
|
||||
printf '%s=%q\n' \
|
||||
REVIEW_MODE "$mode" \
|
||||
REVIEW_MODE_SET_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> .local/review-mode.env
|
||||
}
|
||||
|
||||
review_claim() {
|
||||
local pr="$1"
|
||||
local root
|
||||
root=$(repo_root)
|
||||
cd "$root"
|
||||
mkdir -p .local
|
||||
|
||||
local reviewer=""
|
||||
local max_attempts=3
|
||||
local attempt
|
||||
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
local user_log
|
||||
user_log=".local/review-claim-user-attempt-$attempt.log"
|
||||
|
||||
if reviewer=$(gh api user --jq .login 2>"$user_log"); then
|
||||
printf "%s\n" "$reviewer" >"$user_log"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Claim reviewer lookup failed (attempt $attempt/$max_attempts)."
|
||||
print_relevant_log_excerpt "$user_log"
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$reviewer" ]; then
|
||||
echo "Failed to resolve reviewer login after $max_attempts attempts."
|
||||
return 1
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
local claim_log
|
||||
claim_log=".local/review-claim-assignee-attempt-$attempt.log"
|
||||
|
||||
if gh pr edit "$pr" --add-assignee "$reviewer" >"$claim_log" 2>&1; then
|
||||
echo "review claim succeeded: @$reviewer assigned to PR #$pr"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Claim assignee update failed (attempt $attempt/$max_attempts)."
|
||||
print_relevant_log_excerpt "$claim_log"
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to assign @$reviewer to PR #$pr after $max_attempts attempts."
|
||||
return 1
|
||||
}
|
||||
|
||||
review_checkout_main() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
git fetch origin main
|
||||
git checkout --detach origin/main
|
||||
set_review_mode main
|
||||
|
||||
echo "review mode set to main baseline"
|
||||
echo "branch=$(git branch --show-current)"
|
||||
echo "head=$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
review_checkout_pr() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
git fetch origin "pull/$pr/head:pr-$pr" --force
|
||||
git checkout --detach "pr-$pr"
|
||||
set_review_mode pr
|
||||
|
||||
echo "review mode set to PR head"
|
||||
echo "branch=$(git branch --show-current)"
|
||||
echo "head=$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
review_guard() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
require_artifact .local/review-mode.env
|
||||
require_artifact .local/pr-meta.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/review-mode.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/pr-meta.env
|
||||
|
||||
local branch
|
||||
branch=$(git branch --show-current)
|
||||
local head_sha
|
||||
head_sha=$(git rev-parse HEAD)
|
||||
|
||||
case "${REVIEW_MODE:-}" in
|
||||
main)
|
||||
local expected_main_sha
|
||||
expected_main_sha=$(git rev-parse origin/main)
|
||||
if [ "$head_sha" != "$expected_main_sha" ]; then
|
||||
echo "Review guard failed: expected HEAD at origin/main ($expected_main_sha) for main baseline mode, got $head_sha"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
pr)
|
||||
if [ -z "${PR_HEAD_SHA:-}" ]; then
|
||||
echo "Review guard failed: missing PR_HEAD_SHA in .local/pr-meta.env"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$head_sha" != "$PR_HEAD_SHA" ]; then
|
||||
echo "Review guard failed: expected HEAD at PR_HEAD_SHA ($PR_HEAD_SHA), got $head_sha"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Review guard failed: unknown review mode '${REVIEW_MODE:-}'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "review guard passed"
|
||||
echo "mode=$REVIEW_MODE"
|
||||
echo "branch=$branch"
|
||||
echo "head=$head_sha"
|
||||
}
|
||||
|
||||
review_artifacts_init() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
require_artifact .local/pr-meta.env
|
||||
|
||||
if [ ! -f .local/review.md ]; then
|
||||
cat > .local/review.md <<'EOF_MD'
|
||||
A) TL;DR recommendation
|
||||
|
||||
B) What changed and what is good?
|
||||
|
||||
C) Security findings
|
||||
|
||||
D) What is the PR intent? Is this the most optimal implementation?
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
H) Changelog
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
EOF_MD
|
||||
fi
|
||||
|
||||
if [ ! -f .local/review.json ]; then
|
||||
cat > .local/review.json <<'EOF_JSON'
|
||||
{
|
||||
"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",
|
||||
"changelog": "not_required"
|
||||
}
|
||||
EOF_JSON
|
||||
fi
|
||||
|
||||
echo "review artifact templates are ready"
|
||||
echo "files=.local/review.md .local/review.json"
|
||||
}
|
||||
|
||||
review_validate_artifacts() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" false
|
||||
require_artifact .local/review.md
|
||||
require_artifact .local/review.json
|
||||
require_artifact .local/pr-meta.env
|
||||
require_artifact .local/pr-meta.json
|
||||
|
||||
review_guard "$pr"
|
||||
|
||||
jq . .local/review.json >/dev/null
|
||||
|
||||
local section
|
||||
for section in "A)" "B)" "C)" "D)" "E)" "F)" "G)" "H)" "I)" "J)"; do
|
||||
awk -v s="$section" 'index($0, s) == 1 { found=1; exit } END { exit(found ? 0 : 1) }' .local/review.md || {
|
||||
echo "Missing section header in .local/review.md: $section"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
local recommendation
|
||||
recommendation=$(jq -r '.recommendation // ""' .local/review.json)
|
||||
case "$recommendation" in
|
||||
"READY FOR /prepare-pr"|"NEEDS WORK"|"NEEDS DISCUSSION"|"NOT USEFUL (CLOSE)")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid recommendation in .local/review.json: $recommendation"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local invalid_severity_count
|
||||
invalid_severity_count=$(jq '[.findings[]? | select((.severity // "") != "BLOCKER" and (.severity // "") != "IMPORTANT" and (.severity // "") != "NIT")] | length' .local/review.json)
|
||||
if [ "$invalid_severity_count" -gt 0 ]; then
|
||||
echo "Invalid finding severity in .local/review.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local invalid_findings_count
|
||||
invalid_findings_count=$(jq '[.findings[]? | select((.id|type)!="string" or (.title|type)!="string" or (.area|type)!="string" or (.fix|type)!="string")] | length' .local/review.json)
|
||||
if [ "$invalid_findings_count" -gt 0 ]; then
|
||||
echo "Invalid finding shape in .local/review.json (id/title/area/fix must be strings)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local nit_findings_count
|
||||
nit_findings_count=$(jq '[.findings[]? | select((.severity // "") == "NIT")] | length' .local/review.json)
|
||||
|
||||
local nit_sweep_performed
|
||||
nit_sweep_performed=$(jq -r '.nitSweep.performed // empty' .local/review.json)
|
||||
if [ "$nit_sweep_performed" != "true" ]; then
|
||||
echo "Invalid nit sweep in .local/review.json: nitSweep.performed must be true"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local nit_sweep_status
|
||||
nit_sweep_status=$(jq -r '.nitSweep.status // ""' .local/review.json)
|
||||
case "$nit_sweep_status" in
|
||||
"none")
|
||||
if [ "$nit_findings_count" -gt 0 ]; then
|
||||
echo "Invalid nit sweep in .local/review.json: nitSweep.status is none but NIT findings exist"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
"has_nits")
|
||||
if [ "$nit_findings_count" -lt 1 ]; then
|
||||
echo "Invalid nit sweep in .local/review.json: nitSweep.status is has_nits but no NIT findings exist"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Invalid nit sweep status in .local/review.json: $nit_sweep_status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local invalid_nit_summary_count
|
||||
invalid_nit_summary_count=$(jq '[.nitSweep.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
|
||||
if [ "$invalid_nit_summary_count" -gt 0 ]; then
|
||||
echo "Invalid nit sweep summary in .local/review.json: nitSweep.summary must be a non-empty string"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local issue_validation_performed
|
||||
issue_validation_performed=$(jq -r '.issueValidation.performed // empty' .local/review.json)
|
||||
if [ "$issue_validation_performed" != "true" ]; then
|
||||
echo "Invalid issue validation in .local/review.json: issueValidation.performed must be true"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local issue_validation_source
|
||||
issue_validation_source=$(jq -r '.issueValidation.source // ""' .local/review.json)
|
||||
case "$issue_validation_source" in
|
||||
"linked_issue"|"pr_body"|"both")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid issue validation source in .local/review.json: $issue_validation_source"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local issue_validation_status
|
||||
issue_validation_status=$(jq -r '.issueValidation.status // ""' .local/review.json)
|
||||
case "$issue_validation_status" in
|
||||
"valid"|"unclear"|"invalid"|"already_fixed_on_main")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid issue validation status in .local/review.json: $issue_validation_status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local invalid_issue_summary_count
|
||||
invalid_issue_summary_count=$(jq '[.issueValidation.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
|
||||
if [ "$invalid_issue_summary_count" -gt 0 ]; then
|
||||
echo "Invalid issue validation summary in .local/review.json: issueValidation.summary must be a non-empty string"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local runtime_file_count
|
||||
runtime_file_count=$(jq '[.files[]? | (.path // "") | select(test("^(src|extensions|apps)/")) | select(test("(^|/)__tests__/|\\.test\\.|\\.spec\\.") | not) | select(test("\\.(md|mdx)$") | not)] | length' .local/pr-meta.json)
|
||||
|
||||
local runtime_review_required="false"
|
||||
if [ "$runtime_file_count" -gt 0 ]; then
|
||||
runtime_review_required="true"
|
||||
fi
|
||||
|
||||
local behavioral_sweep_performed
|
||||
behavioral_sweep_performed=$(jq -r '.behavioralSweep.performed // empty' .local/review.json)
|
||||
if [ "$behavioral_sweep_performed" != "true" ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: behavioralSweep.performed must be true"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local behavioral_sweep_status
|
||||
behavioral_sweep_status=$(jq -r '.behavioralSweep.status // ""' .local/review.json)
|
||||
case "$behavioral_sweep_status" in
|
||||
"pass"|"needs_work"|"not_applicable")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid behavioral sweep status in .local/review.json: $behavioral_sweep_status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local behavioral_sweep_risk
|
||||
behavioral_sweep_risk=$(jq -r '.behavioralSweep.silentDropRisk // ""' .local/review.json)
|
||||
case "$behavioral_sweep_risk" in
|
||||
"none"|"present"|"unknown")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid behavioral sweep risk in .local/review.json: $behavioral_sweep_risk"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local invalid_behavioral_summary_count
|
||||
invalid_behavioral_summary_count=$(jq '[.behavioralSweep.summary | select((type != "string") or (gsub("^\\s+|\\s+$";"") | length == 0))] | length' .local/review.json)
|
||||
if [ "$invalid_behavioral_summary_count" -gt 0 ]; then
|
||||
echo "Invalid behavioral sweep summary in .local/review.json: behavioralSweep.summary must be a non-empty string"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local behavioral_branches_is_array
|
||||
behavioral_branches_is_array=$(jq -r 'if (.behavioralSweep.branches | type) == "array" then "true" else "false" end' .local/review.json)
|
||||
if [ "$behavioral_branches_is_array" != "true" ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: behavioralSweep.branches must be an array"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local invalid_behavioral_branch_count
|
||||
invalid_behavioral_branch_count=$(jq '[.behavioralSweep.branches[]? | select((.path|type)!="string" or (.decision|type)!="string" or (.outcome|type)!="string")] | length' .local/review.json)
|
||||
if [ "$invalid_behavioral_branch_count" -gt 0 ]; then
|
||||
echo "Invalid behavioral sweep branch entry in .local/review.json: each branch needs string path/decision/outcome"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local behavioral_branch_count
|
||||
behavioral_branch_count=$(jq '[.behavioralSweep.branches[]?] | length' .local/review.json)
|
||||
|
||||
if [ "$runtime_review_required" = "true" ] && [ "$behavioral_sweep_status" = "not_applicable" ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: runtime file changes require behavioralSweep.status=pass|needs_work"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$runtime_review_required" = "true" ] && [ "$behavioral_branch_count" -lt 1 ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: runtime file changes require at least one branch entry"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$behavioral_sweep_status" = "not_applicable" ] && [ "$behavioral_branch_count" -gt 0 ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: not_applicable cannot include branch entries"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$behavioral_sweep_status" = "pass" ] && [ "$behavioral_sweep_risk" != "none" ]; then
|
||||
echo "Invalid behavioral sweep in .local/review.json: status=pass requires silentDropRisk=none"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$issue_validation_status" != "valid" ]; then
|
||||
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr requires issueValidation.status=valid"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$behavioral_sweep_status" = "needs_work" ]; then
|
||||
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr requires behavioralSweep.status!=needs_work"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$runtime_review_required" = "true" ] && [ "$behavioral_sweep_status" != "pass" ]; then
|
||||
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr on runtime changes requires behavioralSweep.status=pass"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$recommendation" = "READY FOR /prepare-pr" ] && [ "$behavioral_sweep_risk" = "present" ]; then
|
||||
echo "Invalid recommendation in .local/review.json: READY FOR /prepare-pr is not allowed when behavioralSweep.silentDropRisk=present"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local docs_status
|
||||
docs_status=$(jq -r '.docs // ""' .local/review.json)
|
||||
case "$docs_status" in
|
||||
"up_to_date"|"missing"|"not_applicable")
|
||||
;;
|
||||
*)
|
||||
echo "Invalid docs status in .local/review.json: $docs_status"
|
||||
exit 1
|
||||
;;
|
||||
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
|
||||
}
|
||||
|
||||
review_tests() {
|
||||
local pr="$1"
|
||||
shift
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "Usage: scripts/pr review-tests <PR> <test-file> [<test-file> ...]"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
enter_worktree "$pr" false
|
||||
review_guard "$pr"
|
||||
|
||||
local target
|
||||
for target in "$@"; do
|
||||
if [ ! -f "$target" ]; then
|
||||
echo "Missing test target file: $target"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
bootstrap_deps_if_needed
|
||||
|
||||
local run_log=".local/review-tests-run.log"
|
||||
run_quiet_logged "pnpm test" "$run_log" pnpm test -- "$@"
|
||||
|
||||
local missing_run=()
|
||||
for target in "$@"; do
|
||||
local base
|
||||
base=$(basename "$target")
|
||||
if ! rg -F -q "$target" "$run_log" && ! rg -F -q "$base" "$run_log"; then
|
||||
missing_run+=("$target")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing_run[@]}" -gt 0 ]; then
|
||||
echo "These requested targets were not observed in vitest run output:"
|
||||
printf ' - %s\n' "${missing_run[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Security: shell-escape values to prevent command injection when sourced.
|
||||
printf '%s=%q\n' \
|
||||
REVIEW_TESTS_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
REVIEW_TEST_TARGET_COUNT "$#" \
|
||||
> .local/review-tests.env
|
||||
|
||||
echo "review tests passed and were observed in output"
|
||||
}
|
||||
|
||||
review_init() {
|
||||
local pr="$1"
|
||||
enter_worktree "$pr" true
|
||||
|
||||
local json
|
||||
json=$(pr_meta_json "$pr")
|
||||
write_pr_meta_files "$json"
|
||||
|
||||
git fetch origin "pull/$pr/head:pr-$pr" --force
|
||||
local mb
|
||||
mb=$(git merge-base origin/main "pr-$pr")
|
||||
|
||||
# Security: shell-escape values to prevent command injection when sourced.
|
||||
printf '%s=%q\n' \
|
||||
PR_NUMBER "$pr" \
|
||||
MERGE_BASE "$mb" \
|
||||
REVIEW_STARTED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> .local/review-context.env
|
||||
set_review_mode main
|
||||
|
||||
printf '%s\n' "$json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length)}'
|
||||
echo "worktree=$PWD"
|
||||
echo "pr_url=${PR_URL:-}"
|
||||
echo "merge_base=$mb"
|
||||
echo "branch=$(git branch --show-current)"
|
||||
echo "wrote=.local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env"
|
||||
cat <<EOF_GUIDE
|
||||
Review guidance:
|
||||
- Inspect main baseline: scripts/pr review-checkout-main $pr
|
||||
- Inspect PR head: scripts/pr review-checkout-pr $pr
|
||||
- Guard before writeout: scripts/pr review-guard $pr
|
||||
EOF_GUIDE
|
||||
}
|
||||
179
scripts/pr-lib/worktree.sh
Normal file
179
scripts/pr-lib/worktree.sh
Normal file
@@ -0,0 +1,179 @@
|
||||
repo_root() {
|
||||
# Resolve canonical repository root from git common-dir so wrappers work
|
||||
# the same from main checkout or any linked worktree.
|
||||
local base_dir
|
||||
local common_git_dir
|
||||
base_dir="${script_parent_dir:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
|
||||
|
||||
if common_git_dir=$(git -C "$base_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
|
||||
(cd "$(dirname "$common_git_dir")" && pwd)
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback for environments where git common-dir is unavailable.
|
||||
(cd "$base_dir/.." && pwd)
|
||||
}
|
||||
|
||||
ensure_gh_api_auth() {
|
||||
# gh auth status fetches token scopes through REST and misreports quota
|
||||
# failures as invalid credentials. GraphQL verifies the active local token
|
||||
# without sending maintainers through a login that cannot restore quota.
|
||||
if gh_plain api graphql -f 'query=query { viewer { login } }' --jq .data.viewer.login >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat >&2 <<'EOF'
|
||||
GitHub CLI auth is not usable for non-interactive API calls.
|
||||
Run `gh auth login -h github.com` (or refresh the current token) and retry.
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
enter_worktree() {
|
||||
local pr="$1"
|
||||
local reset_to_main="${2:-false}"
|
||||
local invoke_cwd
|
||||
invoke_cwd="$PWD"
|
||||
local root
|
||||
root=$(repo_root)
|
||||
|
||||
if [ "$invoke_cwd" != "$root" ]; then
|
||||
echo "Detected non-root invocation cwd=$invoke_cwd, using canonical root $root"
|
||||
fi
|
||||
|
||||
cd "$root"
|
||||
ensure_gh_api_auth
|
||||
git fetch origin main
|
||||
|
||||
local dir=".worktrees/pr-$pr"
|
||||
if [ -d "$dir" ]; then
|
||||
cd "$dir"
|
||||
git fetch origin main
|
||||
if [ "$reset_to_main" = "true" ]; then
|
||||
git checkout -B "temp/pr-$pr" origin/main
|
||||
fi
|
||||
else
|
||||
git worktree add "$dir" -b "temp/pr-$pr" origin/main
|
||||
cd "$dir"
|
||||
fi
|
||||
|
||||
mkdir -p .local
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
write_pr_meta_files() {
|
||||
local json="$1"
|
||||
|
||||
printf '%s\n' "$json" > .local/pr-meta.json
|
||||
|
||||
# Security: shell-escape all values with printf %q to prevent command injection
|
||||
# via malicious branch names containing $() or backticks. See GHSA-xxxx-xxxx-xxxx.
|
||||
local pr_number pr_url pr_author pr_base pr_head pr_head_sha
|
||||
local pr_head_repo pr_head_repo_url pr_head_owner pr_head_repo_name
|
||||
pr_number=$(printf '%s\n' "$json" | jq -r .number)
|
||||
pr_url=$(printf '%s\n' "$json" | jq -r .url)
|
||||
pr_author=$(printf '%s\n' "$json" | jq -r .author.login)
|
||||
pr_base=$(printf '%s\n' "$json" | jq -r .baseRefName)
|
||||
pr_head=$(printf '%s\n' "$json" | jq -r .headRefName)
|
||||
pr_head_sha=$(printf '%s\n' "$json" | jq -r .headRefOid)
|
||||
pr_head_repo=$(printf '%s\n' "$json" | jq -r .headRepository.nameWithOwner)
|
||||
pr_head_repo_url=$(printf '%s\n' "$json" | jq -r '.headRepository.url // ""')
|
||||
pr_head_owner=$(printf '%s\n' "$json" | jq -r '.headRepositoryOwner.login // ""')
|
||||
pr_head_repo_name=$(printf '%s\n' "$json" | jq -r '.headRepository.name // ""')
|
||||
|
||||
printf '%s=%q\n' \
|
||||
PR_NUMBER "$pr_number" \
|
||||
PR_URL "$pr_url" \
|
||||
PR_AUTHOR "$pr_author" \
|
||||
PR_BASE "$pr_base" \
|
||||
PR_HEAD "$pr_head" \
|
||||
PR_HEAD_SHA "$pr_head_sha" \
|
||||
PR_HEAD_REPO "$pr_head_repo" \
|
||||
PR_HEAD_REPO_URL "$pr_head_repo_url" \
|
||||
PR_HEAD_OWNER "$pr_head_owner" \
|
||||
PR_HEAD_REPO_NAME "$pr_head_repo_name" \
|
||||
> .local/pr-meta.env
|
||||
}
|
||||
|
||||
list_pr_worktrees() {
|
||||
local root
|
||||
root=$(repo_root)
|
||||
cd "$root"
|
||||
|
||||
local dir
|
||||
local found=false
|
||||
for dir in .worktrees/pr-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
found=true
|
||||
local pr
|
||||
if ! pr=$(pr_number_from_worktree_dir "$dir"); then
|
||||
printf 'UNKNOWN\t%s\tUNKNOWN\t(unparseable)\t\n' "$dir"
|
||||
continue
|
||||
fi
|
||||
local info
|
||||
info=$(gh pr view "$pr" --json state,title,url --jq '[.state, .title, .url] | @tsv' 2>/dev/null || printf 'UNKNOWN\t(unavailable)\t')
|
||||
printf '%s\t%s\t%s\n' "$pr" "$dir" "$info"
|
||||
done
|
||||
|
||||
if [ "$found" = "false" ]; then
|
||||
echo "No PR worktrees found."
|
||||
fi
|
||||
}
|
||||
|
||||
gc_pr_worktrees() {
|
||||
local dry_run="${1:-false}"
|
||||
local root
|
||||
root=$(repo_root)
|
||||
cd "$root"
|
||||
|
||||
local dir
|
||||
local removed=0
|
||||
for dir in .worktrees/pr-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
local pr
|
||||
if ! pr=$(pr_number_from_worktree_dir "$dir"); then
|
||||
echo "skipping $dir (could not parse PR number)"
|
||||
continue
|
||||
fi
|
||||
local state
|
||||
state=$(gh pr view "$pr" --json state --jq .state 2>/dev/null || printf 'UNKNOWN')
|
||||
case "$state" in
|
||||
MERGED|CLOSED)
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "would remove $dir (PR #$pr state=$state)"
|
||||
else
|
||||
remove_worktree_if_present "$dir"
|
||||
delete_local_branch_if_safe "temp/pr-$pr"
|
||||
delete_local_branch_if_safe "pr-$pr"
|
||||
delete_local_branch_if_safe "pr-$pr-prep"
|
||||
echo "removed $dir (PR #$pr state=$state)"
|
||||
fi
|
||||
removed=$((removed + 1))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$removed" -eq 0 ]; then
|
||||
if [ "$dry_run" = "true" ]; then
|
||||
echo "No merged/closed PR worktrees eligible for removal."
|
||||
else
|
||||
echo "No merged/closed PR worktrees removed."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
pr_number_from_worktree_dir() {
|
||||
local dir="$1"
|
||||
local token
|
||||
token="${dir##*/pr-}"
|
||||
token="${token%%[^0-9]*}"
|
||||
if [ -n "$token" ]; then
|
||||
printf '%s\n' "$token"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
Reference in New Issue
Block a user