@@ -17,6 +17,8 @@ const PR_URL_RE = /https?:\/\/\S+\/pull\/(\d+)/; // captures PR number
1717const PR_REF_RE = / \( # ? ( \d + ) \) | # ( \d + ) / ; // (#123) or #123
1818const AUTHOR_RE = / @ ( [ A - Z a - z 0 - 9 _ - ] + ) / ;
1919const TRAILING_BY_IN_RE = / \s * ( b y | i n ) \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. */
2224type 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 || / F u l l C h a n g e l o g / 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- / ^ W h a t ' s C h a n g e d / 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 ( / ^ W h a t ' s C h a n g e d / i . test ( section . heading ) ) continue ;
233- if ( / ^ F u l l C h a n g e l o g / 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 ) ;
0 commit comments