mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 12:52:06 +08:00
PR flow: insert changelog entries sorted by PR number
Previously `appendUnreleasedChangelogEntry` always appended new entries to the tail of the target subsection (### Changes / ### Fixes) under ## Unreleased. Every concurrent PR edited the same three-line context window and collided on merge. Change the insertion policy: extract the new entry's PR number, walk the existing bullets, and slot in right before the first existing bullet whose PR number is greater. If the new entry has no PR ref or is the largest so far, fall back to tail-append. This spreads concurrent PRs across the section body so two PRs only collide when their numbers land adjacent. Matching changes in the shell wrapper: - `normalize_pr_changelog_entries` now uses the same ordered-insert when moving a misplaced entry back to ## Unreleased, instead of always appending at the tail. - `validate_changelog_entry_for_pr` no longer enforces "entry must be at section tail". The previous heuristic was already obsolete; we deliberately do not replace it with a global ascending-order check either, because historical Unreleased content is time-ordered not PR-ordered and making PR ordering a hard postcondition would block every new PR behind a full-section rewrite. Ordering is an insertion strategy, not a repo invariant. Covered by new vitest cases for the middle / head / tail / empty / historical-unlinked scenarios.
This commit is contained in:
@@ -186,6 +186,38 @@ function sectionTailInsertIndex(arr, subsectionIndex) {
|
||||
return insertAt;
|
||||
}
|
||||
|
||||
function extractPrNumberFromLine(line) {
|
||||
// 与 TS 侧 extractPrNumber 对齐:只取第一个 PR 引用作为排序键
|
||||
const match = line.match(/(?:\(#(\d+)\)|openclaw#(\d+))/i);
|
||||
const raw = match && (match[1] || match[2]);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const num = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
}
|
||||
|
||||
function orderedInsertIndex(arr, subsectionIndex, nextHeading, newPr) {
|
||||
// 无 PR 号时 fallback 到尾插,保持旧行为
|
||||
if (newPr === undefined) {
|
||||
return sectionTailInsertIndex(arr, subsectionIndex);
|
||||
}
|
||||
for (let i = subsectionIndex + 1; i < nextHeading; i += 1) {
|
||||
const line = arr[i];
|
||||
if (!/^- /.test(line)) {
|
||||
continue;
|
||||
}
|
||||
const existing = extractPrNumberFromLine(line);
|
||||
if (existing === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (existing > newPr) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return sectionTailInsertIndex(arr, subsectionIndex);
|
||||
}
|
||||
|
||||
ensureActiveSection(lines);
|
||||
|
||||
const moved = [];
|
||||
@@ -213,7 +245,6 @@ const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx));
|
||||
|
||||
for (const entry of moved) {
|
||||
const subsectionIndex = ensureSubsection(nextLines, entry.subsection);
|
||||
const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex);
|
||||
|
||||
let nextHeading = nextLines.length;
|
||||
for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) {
|
||||
@@ -229,6 +260,9 @@ for (const entry of moved) {
|
||||
if (alreadyPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPr = extractPrNumberFromLine(entry.line);
|
||||
const insertAt = orderedInsertIndex(nextLines, subsectionIndex, nextHeading, newPr);
|
||||
nextLines.splice(insertAt, 0, entry.line);
|
||||
}
|
||||
|
||||
@@ -272,18 +306,24 @@ validate_changelog_entry_for_pr() {
|
||||
local pr_pattern
|
||||
pr_pattern="(#$pr|openclaw#$pr)"
|
||||
|
||||
# 只验证三件事:
|
||||
# 1. 本 PR 条目存在于 ## Unreleased 之下
|
||||
# 2. 条目落在某个 ### 子 section 里
|
||||
# 3. 若有 contrib 信息,同一行含 `thanks @<contrib>`
|
||||
#
|
||||
# 不再对整个 section 做 PR 号全局单调性检查。PR 号升序只是插入策略
|
||||
# (由 src/infra/changelog-unreleased.ts 执行),不是存量不变式 ——
|
||||
# 历史 CHANGELOG 是按合并时间 append 的,本来就不严格升序,
|
||||
# 把它当硬门会让所有新 PR 都被卡住。
|
||||
local validation_output
|
||||
if ! validation_output=$(awk -v pr_pattern="$pr_pattern" '
|
||||
BEGIN {
|
||||
current_release = ""
|
||||
current_section = ""
|
||||
file_line_count = 0
|
||||
issue_count = 0
|
||||
pr_count = 0
|
||||
}
|
||||
{
|
||||
changelog[FNR] = $0
|
||||
file_line_count = FNR
|
||||
|
||||
if ($0 ~ /^## /) {
|
||||
current_release = $0
|
||||
current_section = ""
|
||||
@@ -308,36 +348,14 @@ END {
|
||||
if (pr_sections[entry_line] == "") {
|
||||
printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_text[entry_line]
|
||||
issue_count++
|
||||
continue
|
||||
}
|
||||
|
||||
section_name = pr_sections[entry_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
|
||||
}
|
||||
printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_text[entry_line]
|
||||
printf "Found existing 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
|
||||
}
|
||||
|
||||
print "changelog placement validated: PR-linked entries are appended at section tail"
|
||||
print "changelog placement validated: PR-linked entry exists under ## Unreleased in a subsection"
|
||||
}
|
||||
' CHANGELOG.md); then
|
||||
printf '%s\n' "$validation_output"
|
||||
|
||||
@@ -21,7 +21,7 @@ const baseChangelog = `# Changelog
|
||||
`;
|
||||
|
||||
describe("appendUnreleasedChangelogEntry", () => {
|
||||
it("appends to the end of the requested unreleased section", () => {
|
||||
it("falls back to appending when the new entry has no PR ref", () => {
|
||||
const next = appendUnreleasedChangelogEntry(baseChangelog, {
|
||||
section: "Fixes",
|
||||
entry: "New fix entry.",
|
||||
@@ -34,6 +34,165 @@ describe("appendUnreleasedChangelogEntry", () => {
|
||||
expect(next).toContain("## 2026.4.5");
|
||||
});
|
||||
|
||||
it("inserts a PR-linked entry ordered by PR number in the middle", () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Earlier change (#100). Thanks @alice
|
||||
- Later change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`;
|
||||
|
||||
const next = appendUnreleasedChangelogEntry(content, {
|
||||
section: "Changes",
|
||||
entry: "Middle change (#200). Thanks @bob",
|
||||
});
|
||||
|
||||
expect(next).toBe(`# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Earlier change (#100). Thanks @alice
|
||||
- Middle change (#200). Thanks @bob
|
||||
- Later change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`);
|
||||
});
|
||||
|
||||
it("inserts a PR-linked entry with the smallest number at the top of the section", () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Later change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`;
|
||||
|
||||
const next = appendUnreleasedChangelogEntry(content, {
|
||||
section: "Changes",
|
||||
entry: "Earliest change (#50). Thanks @alice",
|
||||
});
|
||||
|
||||
expect(next).toBe(`# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Earliest change (#50). Thanks @alice
|
||||
- Later change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`);
|
||||
});
|
||||
|
||||
it("inserts a PR-linked entry with the largest number at the tail of the section", () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Earlier change (#100). Thanks @alice
|
||||
- Later change (#200). Thanks @bob
|
||||
|
||||
## 2026.4.5
|
||||
`;
|
||||
|
||||
const next = appendUnreleasedChangelogEntry(content, {
|
||||
section: "Changes",
|
||||
entry: "Newest change (#500). Thanks @carol",
|
||||
});
|
||||
|
||||
expect(next).toBe(`# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Earlier change (#100). Thanks @alice
|
||||
- Later change (#200). Thanks @bob
|
||||
- Newest change (#500). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`);
|
||||
});
|
||||
|
||||
it("inserts into an empty sub-section while preserving surrounding spacing", () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- Existing fix.
|
||||
|
||||
## 2026.4.5
|
||||
`;
|
||||
|
||||
const next = appendUnreleasedChangelogEntry(content, {
|
||||
section: "Changes",
|
||||
entry: "First change (#42). Thanks @alice",
|
||||
});
|
||||
|
||||
expect(next).toContain("- First change (#42). Thanks @alice");
|
||||
// 新条目落在空 Changes 里、位于 Fixes 之前
|
||||
const changesIdx = next.indexOf("### Changes");
|
||||
const firstIdx = next.indexOf("- First change");
|
||||
const fixesIdx = next.indexOf("### Fixes");
|
||||
expect(changesIdx).toBeLessThan(firstIdx);
|
||||
expect(firstIdx).toBeLessThan(fixesIdx);
|
||||
// Fixes 下原有条目未被打乱
|
||||
expect(next).toContain(`### Fixes
|
||||
|
||||
- Existing fix.`);
|
||||
});
|
||||
|
||||
it("skips historical bullets without PR refs when deciding order", () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Legacy unlinked entry without a PR ref.
|
||||
- Linked change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`;
|
||||
|
||||
const next = appendUnreleasedChangelogEntry(content, {
|
||||
section: "Changes",
|
||||
entry: "Linked change (#150). Thanks @bob",
|
||||
});
|
||||
|
||||
// 150 < 300,新条目应该插在 (#300) 前面;没有 PR 号的历史行不当排序锚
|
||||
expect(next).toBe(`# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Legacy unlinked entry without a PR ref.
|
||||
- Linked change (#150). Thanks @bob
|
||||
- Linked change (#300). Thanks @carol
|
||||
|
||||
## 2026.4.5
|
||||
`);
|
||||
});
|
||||
|
||||
it("avoids duplicating an existing entry", () => {
|
||||
const next = appendUnreleasedChangelogEntry(baseChangelog, {
|
||||
section: "Changes",
|
||||
|
||||
@@ -37,12 +37,24 @@ function entriesAreEquivalent(existingLine: string, newBullet: string): boolean
|
||||
return existingWithoutRef === newWithoutRef;
|
||||
}
|
||||
|
||||
function extractPrNumber(line: string): number | undefined {
|
||||
// 只取行里第一个 PR 引用作为排序键,避免 "(#123, #456)" 取到 456
|
||||
const match = line.match(/(?:\(#(\d+)\)|openclaw#(\d+))/i);
|
||||
const raw = match?.[1] ?? match?.[2];
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const num = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
}
|
||||
|
||||
function findSectionRange(
|
||||
lines: string[],
|
||||
section: UnreleasedSection,
|
||||
): {
|
||||
start: number;
|
||||
insertAt: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
} {
|
||||
const unreleasedIndex = lines.findIndex((line) => line.trim() === "## Unreleased");
|
||||
if (unreleasedIndex === -1) {
|
||||
@@ -65,20 +77,49 @@ function findSectionRange(
|
||||
throw new Error(`CHANGELOG.md is missing the '${sectionHeading}' section under Unreleased.`);
|
||||
}
|
||||
|
||||
let insertAt = lines.length;
|
||||
// bodyEnd 指向下一个 heading(### 或 ##),bodyStart 紧跟 section heading
|
||||
let bodyEnd = lines.length;
|
||||
for (let index = sectionIndex + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.startsWith("### ") || line.startsWith("## ")) {
|
||||
insertAt = index;
|
||||
bodyEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (insertAt > sectionIndex + 1 && lines[insertAt - 1]?.trim() === "") {
|
||||
insertAt -= 1;
|
||||
while (bodyEnd > sectionIndex + 1 && lines[bodyEnd - 1]?.trim() === "") {
|
||||
bodyEnd -= 1;
|
||||
}
|
||||
|
||||
return { start: sectionIndex, insertAt };
|
||||
return { start: sectionIndex, bodyStart: sectionIndex + 1, bodyEnd };
|
||||
}
|
||||
|
||||
function resolveOrderedInsertIndex(
|
||||
lines: string[],
|
||||
bodyStart: number,
|
||||
bodyEnd: number,
|
||||
newPr: number | undefined,
|
||||
): number {
|
||||
// 无 PR 号(手写条目等)fallback 到尾插,保持旧行为
|
||||
if (newPr === undefined) {
|
||||
return bodyEnd;
|
||||
}
|
||||
|
||||
// 按 PR 号升序找第一个 PR 号大于 newPr 的已有条目,插到它前面
|
||||
// 没有 PR 号的历史行(极少见)当成边界,原地跳过
|
||||
for (let index = bodyStart; index < bodyEnd; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.startsWith("- ")) {
|
||||
continue;
|
||||
}
|
||||
const existingPr = extractPrNumber(line);
|
||||
if (existingPr === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (existingPr > newPr) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return bodyEnd;
|
||||
}
|
||||
|
||||
export function appendUnreleasedChangelogEntry(
|
||||
@@ -99,7 +140,17 @@ export function appendUnreleasedChangelogEntry(
|
||||
return content;
|
||||
}
|
||||
|
||||
const { insertAt } = findSectionRange(lines, params.section);
|
||||
lines.splice(insertAt, 0, bullet, "");
|
||||
const { bodyStart, bodyEnd } = findSectionRange(lines, params.section);
|
||||
const newPr = extractPrNumber(bullet);
|
||||
const insertAt = resolveOrderedInsertIndex(lines, bodyStart, bodyEnd, newPr);
|
||||
|
||||
// 空 section:插到 heading 之后并补一个空行分隔
|
||||
if (bodyEnd === bodyStart) {
|
||||
lines.splice(insertAt, 0, bullet, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// 非空 section:单独插一行,复用已有前后空行
|
||||
lines.splice(insertAt, 0, bullet);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user