mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 04:47:23 +01:00
fix(restamp): CHANGELOG_DUPLICATE_HEADING refuses only duplicates the PR introduces
checkChangelog takes the base branch's CHANGELOG: a heading the base already carries N times may appear N times on the PR (inherited released history), and only a further copy is a duplicate. gstack-restamp passes origin/<base>'s CHANGELOG at the plan's base sha, so a base like gbrain's master, which heads one version twice, can be stamped; a duplicate the PR adds is still refused with nothing written. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9a02d4ca2a
commit
381f7e8215
6 files changed
+189
-15
No files matched your search
+30
-8
@@ -2,8 +2,9 @@
|
||||
* changelog-check — CHANGELOG re-heading and checks for gstack-restamp (C2)
|
||||
* and the tree receipt (C3). A restamp moves the PR's section (`[Unreleased]`
|
||||
* or the version currently stamped on the branch) to the top under the new
|
||||
* version, above its predecessor's entry. Duplicate version headings and a
|
||||
* top entry that is not VERSION are errors. The tree receipt uses
|
||||
* version, above its predecessor's entry. Duplicate version headings beyond
|
||||
* those the base branch already carries, and a top entry that is not VERSION,
|
||||
* are errors. The tree receipt uses
|
||||
* `normalizeChangelog` to accept a heading move as a stamp, never a change.
|
||||
*/
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
@@ -44,15 +45,36 @@ export function parseChangelog(text: string): ParsedChangelog {
|
||||
return { header, sections };
|
||||
}
|
||||
|
||||
/** Duplicate headings, and (when `expectedTop` is given) the top entry equals VERSION. */
|
||||
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md'): ChangelogError[] {
|
||||
/**
|
||||
* Duplicate headings, and (when `expectedTop` is given) the top entry equals
|
||||
* VERSION. `base` holds readings of the base branch's CHANGELOG (the base tip,
|
||||
* and the commit the PR's own lines are counted from): a heading it already carries N
|
||||
* times may appear N times here (history the PR inherited and may not
|
||||
* rewrite); only an occurrence beyond that count is a duplicate the PR
|
||||
* introduced. The count is per heading, not per section body: an inherited
|
||||
* copy the PR edits or replaces is caught by the train's released-history
|
||||
* checks (`ownEntryAboveMain`, `changelogDamage`), not here.
|
||||
*/
|
||||
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md', base: string[] = []): ChangelogError[] {
|
||||
const errors: ChangelogError[] = [];
|
||||
const parsed = parseChangelog(text);
|
||||
const seen = new Map<string, number>();
|
||||
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
||||
const inherited = new Map<string, number>();
|
||||
for (const reading of base) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of parseChangelog(reading).sections) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
|
||||
for (const [k, n] of counts) inherited.set(k, Math.max(inherited.get(k) ?? 0, n));
|
||||
}
|
||||
const seen = new Map<string, number[]>();
|
||||
for (const s of parsed.sections) {
|
||||
const key = s.unreleased ? 'Unreleased' : s.version!;
|
||||
if (seen.has(key)) errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${key} already headed at line ${seen.get(key)! + 1}` });
|
||||
else seen.set(key, s.start);
|
||||
const k = key(s);
|
||||
const starts = seen.get(k) ?? [];
|
||||
const allowed = Math.max(1, inherited.get(k) ?? 0);
|
||||
if (starts.length >= allowed) {
|
||||
const where = allowed > 1 ? `headed ${starts.length + 1} times (the base carries it ${allowed} times; first at line ${starts[0]! + 1})` : `already headed at line ${starts[0]! + 1}`;
|
||||
errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${k} ${where}` });
|
||||
}
|
||||
seen.set(k, [...starts, s.start]);
|
||||
}
|
||||
if (expectedTop) {
|
||||
const top = parsed.sections[0];
|
||||
|
||||
+13
-3
@@ -380,9 +380,19 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
|
||||
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
|
||||
if (r.moved) fs.writeFileSync(clPath, r.text);
|
||||
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!);
|
||||
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}; nothing written to the branch`); }
|
||||
cl.status = 'done'; cl.detail = r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`;
|
||||
// Inherited duplicates are allowed at the larger count of two base readings: the base tip and
|
||||
// `since`, the commit the branch's own lines are counted from (its fork point, or the predecessor's
|
||||
// head in a gate-ahead stamp), so neither a duplicate main collapsed after the fork nor one it
|
||||
// gained since refuses the PR. A reading the base cannot show (the PR adds the file) is named.
|
||||
const readings = [...new Set([plan.baseSha, since])].map(sha => ({ sha, cl: git(o.repoRoot, ['show', `${sha}:${clRel}`]) }));
|
||||
const readable = readings.filter(x => x.cl.ok);
|
||||
const unreadable = readings.filter(x => !x.cl.ok).map(x => `${x.sha.slice(0, 12)}:${clRel}`);
|
||||
const baseNote = unreadable.length === 0 ? '' : readable.length === 0
|
||||
? `; base ${clRel} unreadable at ${unreadable.join(', ')}, so every duplicate heading counts`
|
||||
: `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`;
|
||||
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n'));
|
||||
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); }
|
||||
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote;
|
||||
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
|
||||
injected('changelog');
|
||||
|
||||
|
||||
+2
-2
@@ -226,8 +226,8 @@ export const RESULT_CODES = {
|
||||
},
|
||||
CHANGELOG_DUPLICATE_HEADING: {
|
||||
anchor: 'changelog-duplicate-heading',
|
||||
summary: 'two CHANGELOG sections carry the same version heading',
|
||||
fix: 'merge the duplicate sections into one entry under the new version',
|
||||
summary: 'two CHANGELOG sections carry the same version heading, beyond any duplicate the base branch already carries',
|
||||
fix: 'merge the duplicate sections the PR introduced into one entry under the new version',
|
||||
},
|
||||
CHANGELOG_TOP_MISMATCH: {
|
||||
anchor: 'changelog-top-mismatch',
|
||||
|
||||
Reference in new issue
Block a user