openclaw/scripts/pr-lib/changelog.sh

336 lines
8.5 KiB
Bash

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) {
return arr.findIndex((line) => line.trim() === "## 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;
}
ensureActiveSection(lines);
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 === "## Unreleased") {
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_entry_for_pr() {
local pr="$1"
local contrib="$2"
local added_lines
added_lines=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md | awk '
/^\+\+\+/ { next }
/^\+/ { print substr($0, 2) }
')
if [ -z "$added_lines" ]; then
echo "CHANGELOG.md is in diff but no added lines were detected."
exit 1
fi
local pr_pattern
pr_pattern="(#$pr|openclaw#$pr)"
local with_pr
with_pr=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" || true)
if [ -z "$with_pr" ]; then
echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))."
exit 1
fi
local diff_file
diff_file=$(mktemp)
git diff --unified=0 origin/main...HEAD -- CHANGELOG.md > "$diff_file"
if ! awk -v pr_pattern="$pr_pattern" '
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 {
for (idx = 1; idx <= pr_added_count; idx++) {
entry_line = pr_added_lines[idx]
release_line = 0
section_line = 0
for (i = entry_line; i >= 1; i--) {
if (section_line == 0 && changelog[i] ~ /^### /) {
section_line = i
continue
}
if (changelog[i] ~ /^## /) {
release_line = i
break
}
}
if (release_line == 0 || changelog[release_line] != "## Unreleased") {
printf "CHANGELOG.md PR-linked entry must be in ## Unreleased: line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
if (section_line == 0) {
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line]
issue_count++
continue
}
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 [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
local with_pr_and_thanks
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true)
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
echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)"
}
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" | rg -o '#[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" | rg -q -F "$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"
}