Skip to content

Commit b4a4dd2

Browse files
authored
Merge pull request #87 from nyaomaru/fix/preserve-custom-release-note
fix(changelog): preserve custom release-note sections like "What's New"
2 parents 5c05e5b + f1452f2 commit b4a4dd2

File tree

4 files changed

+168
-16
lines changed

4 files changed

+168
-16
lines changed

src/utils/llm-output.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ async function buildOutputFromReleaseNotes(
206206
} = params;
207207

208208
const parsedRelease = parseReleaseNotes(releaseBody, { owner, repo });
209-
if (!parsedRelease.items.length) return null;
209+
const hasAdditionalSections = Boolean(parsedRelease.sections?.length);
210+
if (!parsedRelease.items.length && !hasAdditionalSections) return null;
210211

211212
fallbackReasons.push(
212213
'Used GitHub Release Notes as the source (no model call)',
@@ -222,11 +223,14 @@ async function buildOutputFromReleaseNotes(
222223
});
223224

224225
const titlesForLLM = buildTitlesForClassification(parsedRelease.items);
225-
let categories = await classifyTitles(titlesForLLM, provider.name);
226-
// Mark that an LLM was used when classification ran with a valid provider key.
227-
aiUsed = aiUsed || hasProviderKey;
228-
// Heuristic tuning: ensure typing/contract corrections are grouped under Fixed.
229-
categories = tuneCategoriesByTitle(parsedRelease.items, categories);
226+
let categories: Record<string, string[]> = {};
227+
if (titlesForLLM.length) {
228+
categories = await classifyTitles(titlesForLLM, provider.name);
229+
// Mark AI usage only when classification had input and a provider key is available.
230+
aiUsed = aiUsed || hasProviderKey;
231+
// Heuristic tuning: ensure typing/contract corrections are grouped under Fixed.
232+
categories = tuneCategoriesByTitle(parsedRelease.items, categories);
233+
}
230234

231235
const section = buildSectionFromRelease({
232236
version,

src/utils/release.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const PR_URL_RE = /https?:\/\/\S+\/pull\/(\d+)/; // captures PR number
1717
const PR_REF_RE = /\(#?(\d+)\)|#(\d+)/; // (#123) or #123
1818
const AUTHOR_RE = /@([A-Za-z0-9_-]+)/;
1919
const TRAILING_BY_IN_RE = /\s*(by|in)\s*$/i; // strip noisy trailing tokens
20+
const TYPOGRAPHIC_APOSTROPHE_RE = /[`´]/g;
21+
const COLLAPSE_WHITESPACE_RE = /\s+/g;
2022

2123
/** GitHub repository owner/name pair used for compare link construction. */
2224
type RepoInfo = {
@@ -130,6 +132,42 @@ function collectH2Sections(body: string): RawSection[] {
130132
return sections;
131133
}
132134

135+
/**
136+
* Normalize heading text to compare release-note section names robustly.
137+
* WHY: Users often edit release notes with typographic apostrophes or inconsistent spacing.
138+
* @param heading Raw heading content.
139+
* @returns Normalized heading for case-insensitive matching.
140+
*/
141+
function normalizeHeading(heading: string): string {
142+
return heading
143+
.trim()
144+
.toLowerCase()
145+
.replace(TYPOGRAPHIC_APOSTROPHE_RE, "'")
146+
.replace(COLLAPSE_WHITESPACE_RE, ' ');
147+
}
148+
149+
/**
150+
* Check whether an H2 heading refers to the canonical "What's Changed" section.
151+
* @param heading Heading text without markdown markers.
152+
* @returns True when the heading is a "What's Changed" variant.
153+
*/
154+
function isWhatsChangedHeading(heading: string): boolean {
155+
const normalizedHeading = normalizeHeading(heading);
156+
return (
157+
normalizedHeading.startsWith("what's changed") ||
158+
normalizedHeading.startsWith('whats changed')
159+
);
160+
}
161+
162+
/**
163+
* Check whether an H2 heading is the "Full Changelog" block.
164+
* @param heading Heading text without markdown markers.
165+
* @returns True when the heading denotes the full changelog section.
166+
*/
167+
function isFullChangelogHeading(heading: string): boolean {
168+
return normalizeHeading(heading).startsWith('full changelog');
169+
}
170+
133171
/**
134172
* Collect the bullet lines under the provided "What's Changed" section lines.
135173
* @param lines Section content lines following the heading.
@@ -139,7 +177,7 @@ function parseWhatsChangedLines(lines: string[]): string[] {
139177
const collected: string[] = [];
140178
for (const rawLine of lines) {
141179
const line = rawLine.trim();
142-
if (!line || /Full Changelog/i.test(line)) continue;
180+
if (!line || FULL_CHANGELOG_RE.test(line)) continue;
143181
collected.push(line);
144182
}
145183
return collected;
@@ -216,23 +254,26 @@ export function parseReleaseNotes(
216254
if (!body) return { items };
217255

218256
const h2Sections = collectH2Sections(body);
219-
const whatsChangedSection = h2Sections.find((section) =>
220-
/^What's Changed/i.test(section.heading),
221-
);
222-
const whatsChangedLines = whatsChangedSection
223-
? parseWhatsChangedLines(whatsChangedSection.lines)
224-
: [];
257+
const whatsChangedLines = h2Sections
258+
.filter((section) => isWhatsChangedHeading(section.heading))
259+
.flatMap((section) => parseWhatsChangedLines(section.lines));
225260

226261
for (const line of whatsChangedLines) {
227262
const item = parseReleaseLine(line, repo);
228263
if (item) items.push(item);
229264
}
230265

266+
const seenSections = new Set<string>();
231267
for (const section of h2Sections) {
232-
if (/^What's Changed/i.test(section.heading)) continue;
233-
if (/^Full Changelog/i.test(section.heading)) continue;
268+
if (isWhatsChangedHeading(section.heading)) continue;
269+
if (isFullChangelogHeading(section.heading)) continue;
234270
const structured = toReleaseSection(section);
235-
if (structured) additionalSections.push(structured);
271+
if (!structured) continue;
272+
273+
const sectionKey = `${normalizeHeading(structured.heading)}\n${structured.body.trim()}`;
274+
if (seenSections.has(sectionKey)) continue;
275+
seenSections.add(sectionKey);
276+
additionalSections.push(structured);
236277
}
237278

238279
const fullChangelog = extractFullChangelog(body, repo);

tests/utils/llm-output.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,73 @@ describe('llm-output', () => {
8383
expect(result.llm.new_section_markdown).toContain('### Added');
8484
expect(result.llm.new_section_markdown).toContain('- Add feature');
8585
});
86+
87+
test('preserves custom release-note sections when no items are parsed', async () => {
88+
const whatsNewHeading = 'What\u2019s New \u{1F680}';
89+
const result = await buildChangelogLlmOutput({
90+
owner: 'octo',
91+
repo: 'repo',
92+
version: '1.0.0',
93+
date: '2024-01-01',
94+
releaseRef: 'v1.0.0',
95+
prevRef: 'v0.9.0',
96+
releaseBody: [
97+
`## ${whatsNewHeading}`,
98+
'Introduces the `assert` helper and usage examples.',
99+
'',
100+
'**Full Changelog**: v0.9.0...v1.0.0',
101+
].join('\n'),
102+
existingChangelog: '',
103+
commitList: [],
104+
prs: '',
105+
prMapBySha: {},
106+
titleToPr: {},
107+
provider: mockProvider,
108+
hasProviderKey: false,
109+
token: undefined,
110+
});
111+
112+
expect(result.fallbackReasons).toContain(
113+
'Used GitHub Release Notes as the source (no model call)',
114+
);
115+
expect(result.llm.new_section_markdown).toContain(
116+
'## [v1.0.0] - 2024-01-01',
117+
);
118+
expect(result.llm.new_section_markdown).toContain(`### ${whatsNewHeading}`);
119+
expect(result.llm.new_section_markdown).toContain(
120+
'Introduces the `assert` helper and usage examples.',
121+
);
122+
expect(result.llm.new_section_markdown).toContain(
123+
'**Full Changelog**: https://github.com/octo/repo/compare/v0.9.0...v1.0.0',
124+
);
125+
});
126+
127+
test('keeps fallback note when provider key exists but release notes have no items', async () => {
128+
const result = await buildChangelogLlmOutput({
129+
owner: 'octo',
130+
repo: 'repo',
131+
version: '1.0.0',
132+
date: '2024-01-01',
133+
releaseRef: 'v1.0.0',
134+
prevRef: 'v0.9.0',
135+
releaseBody: [
136+
'## What\u2019s New',
137+
'User-facing highlights only.',
138+
'',
139+
'**Full Changelog**: v0.9.0...v1.0.0',
140+
].join('\n'),
141+
existingChangelog: '',
142+
commitList: [],
143+
prs: '',
144+
prMapBySha: {},
145+
titleToPr: {},
146+
provider: mockProvider,
147+
hasProviderKey: true,
148+
token: undefined,
149+
});
150+
151+
expect(result.aiUsed).toBe(false);
152+
expect(result.llm.pr_body).toContain('Generated without LLM');
153+
expect(result.llm.new_section_markdown).toContain('### What\u2019s New');
154+
});
86155
});

tests/utils/release.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,44 @@ describe('release utils', () => {
3636
});
3737
});
3838

39+
test('parseReleaseNotes preserves custom sections and handles typographic headings', () => {
40+
const whatsNewHeading = 'What\u2019s New \u{1F680}';
41+
const body = [
42+
'# v1.2.0',
43+
'',
44+
`## ${whatsNewHeading}`,
45+
'Includes user-facing highlights and examples.',
46+
'',
47+
'## What\u2019s Changed',
48+
'- feat: Add assert helper by @alice in #321',
49+
'',
50+
`## ${whatsNewHeading}`,
51+
'Includes user-facing highlights and examples.',
52+
'',
53+
'## Migration Notes',
54+
'No migration steps required.',
55+
'',
56+
'**Full Changelog**: v1.1.13...v1.2.0',
57+
].join('\n');
58+
59+
const parsed = parseReleaseNotes(body, { owner: 'acme', repo: 'repo' });
60+
expect(parsed.items).toHaveLength(1);
61+
expect(parsed.items[0].title).toBe('Add assert helper');
62+
expect(parsed.sections).toEqual([
63+
{
64+
heading: whatsNewHeading,
65+
body: 'Includes user-facing highlights and examples.',
66+
},
67+
{
68+
heading: 'Migration Notes',
69+
body: 'No migration steps required.',
70+
},
71+
]);
72+
expect(parsed.fullChangelog).toBe(
73+
'https://github.com/acme/repo/compare/v1.1.13...v1.2.0',
74+
);
75+
});
76+
3977
test('buildSectionFromRelease formats bullets with author and PR link', () => {
4078
const items = [
4179
{

0 commit comments

Comments
 (0)